58 Commits

Author SHA1 Message Date
e3d2752a4c style: kurangi borderRadius card dari 12 ke 8 agar tampilan lebih tegas 2026-05-21 14:04:25 +08:00
4dcfcbb7a7 style: standardisasi borderRadius approval dan notification — cards 10→12, notifIconContainer 21→100 2026-05-21 13:56:35 +08:00
f27707eb74 style: standardisasi borderRadius announcement — cards 10→12, iconBox 10→8, divisionCircle 13→100 2026-05-21 13:56:14 +08:00
e4e8b44c45 style: standardisasi borderRadius component — cards 10→12, icon circles 20/22→100, chip 5→20, guideCard 16→12 2026-05-21 13:55:49 +08:00
79c9c9046c style: standardisasi borderRadius modal — bottom sheet 18→20, confirm 14→12, decorativeCircle 150→100 2026-05-21 13:54:48 +08:00
f6122fb35a style: standardisasi borderRadius input — inputRoundForm 5→12, verificationCell 15→12 2026-05-21 13:54:18 +08:00
2ae88ff2e3 style: standardisasi borderRadius button — btnFiturMenu 15→20, labelStatus 10→20 2026-05-21 13:53:58 +08:00
fbf25bdd59 style: standardisasi borderRadius card — semua card/container ke 12, sectionBadge ke 20 2026-05-21 13:53:41 +08:00
600218cb11 style: tambah token borderRadius (round04, round12, roundFull) dan update round05 5→8 2026-05-21 13:52:30 +08:00
18eea92cfd feat: bump version ke 2.2.0 dan set default border ImageUser
- Versi app naik dari 2.1.0 ke 2.2.0 (versionCode 16→17, buildNumber 9→10)
- ImageUser: border default true agar avatar selalu tampil dengan border
2026-05-21 13:47:36 +08:00
c21d928701 feat: redesign halaman detail acara kalender divisi dengan pola sectionCard 2026-05-20 17:08:21 +08:00
d8a50cbc75 feat: redesign halaman anggota diskusi dan info divisi dengan pola sectionCard
- redesign discussion/member/[id]: flat list airy, bubble avatar row, header jumlah anggota, button tambah ikuti pola create
- redesign division/[id]/info: sectionCard deskripsi, list anggota dengan role label, drawer menu konsisten
- fix division/[id]/add-member: border warna mengikuti tema
2026-05-20 17:01:08 +08:00
59459e2c22 Merge pull request 'amalia/19-mei-26' (#50) from amalia/19-mei-26 into join
Reviewed-on: #50
2026-05-19 17:18:34 +08:00
a61c194ece feat: redesign halaman tambah dan edit diskusi divisi dengan pola sectionCard dan fileGrid 2026-05-19 15:17:07 +08:00
2be59b5ac6 feat: redesign halaman tambah dan edit diskusi
- Ganti ButtonSelect dan BorderBottomItem dengan pola sectionCard + fileGrid
- Tambah getFileIcon/getFileColor helper dan ikon berwarna per tipe file
- Bagian anggota pada create menggunakan listItemCard dengan avatar ImageUser
- Terapkan deduplication file berdasarkan nama dengan toast notifikasi
- Bersihkan komentar lama dan sederhanakan logic validasi
2026-05-19 15:10:18 +08:00
d272b96e53 fix: ganti warna desc diskusi dan pindahkan inline styles ke styles.ts
- Ganti warna teks deskripsi dari dimmed ke text pada list diskusi umum dan divisi
- Tambah class discussionHeaderPadding, discussionListPadding, discussionTitleCol,
  discussionDescMargin, discussionEmptyText ke component.styles.ts
- Ganti semua inline style dengan themed object (warna dinamis) dan Styles.* (statis)
2026-05-19 14:47:37 +08:00
6d0203cc7d fix: hindari refetch saat mount pada halaman list pengumuman
Gunakan useRef untuk skip efek di render pertama sehingga refetch
hanya dipanggil saat state update berubah (setelah CRUD), bukan
setiap kali halaman dibuka.
2026-05-19 14:41:39 +08:00
165f423798 fix: cegah upload file duplikat pada form tambah dan edit pengumuman
Cek nama file sebelum menambahkan ke list, skip jika sudah ada.
Gunakan nama file (bukan URI) karena Android dapat menghasilkan URI berbeda
untuk file yang sama di setiap sesi picker.
2026-05-19 14:39:23 +08:00
0cb085caa8 feat: redesign halaman pengumuman dan pindahkan styles ke announcement.styles.ts
- Redesign list, detail, create, dan edit pengumuman menggunakan pola sectionCard
- Buat constants/styles/announcement.styles.ts untuk class announcementList* dan announcementDetail*
- Hapus local StyleSheet S dari index.tsx dan [id].tsx, ganti dengan Styles global
- Tambah getFileIcon/getFileColor helper dan fileGrid berwarna per tipe file
- Sesuaikan edit/[id].tsx dengan pola design create.tsx
2026-05-19 14:27:29 +08:00
2bacc47d75 fix: sesuaikan warna border dengan tema pada halaman pilih anggota dan admin divisi 2026-05-19 12:06:12 +08:00
fcd3dc7537 fix: samakan jarak dan style label filter di discussion, division, member, position 2026-05-19 11:55:58 +08:00
0cbf12eea7 Merge pull request 'amalia/18-mei-26' (#49) from amalia/18-mei-26 into join
Reviewed-on: #49
2026-05-18 17:28:18 +08:00
85aca330e5 feat: filter approval berdasarkan group dan perbaikan tampilan riwayat
- Simpan idGroup user ke Redux saat login agar perbandingan group bisa dilakukan
- Filter button persetujuan project: isApprover hanya tampil jika group sama
- Filter button persetujuan division/task: isApprover hanya tampil jika group sama
- Pass idGroup ke SectionTanggalTugasProject dan SectionTanggalTugasTask dari parent
- Samakan warna icon, label, dan nama pada riwayat persetujuan
- Ubah bg alasan penolakan dari merah ke netral, label tetap merah
- Ekstrak inline styles ModalRiwayatApproval ke approval.styles.ts
2026-05-18 14:52:30 +08:00
3f113a4049 fix: urutkan grup tanggal notifikasi dari terbaru ke terlama 2026-05-18 11:43:16 +08:00
f873921325 fix: ubah label tombol "Pilih dari File Proyek" menjadi "Pilih dari File Kegiatan ini" 2026-05-18 11:37:04 +08:00
90419b5d15 feat: tambah fitur tandai terbaca per notifikasi dan ekstrak styles
- Tambah fungsi handleMarkOneRead untuk tandai satu notifikasi terbaca tanpa navigasi
- Tambah tombol "Tandai dibaca" pada tiap notifikasi yang belum terbaca
- Buat notification.styles.ts dengan 8 class styles untuk notification screen
- Daftarkan NotificationStyles ke constants/styles/index.ts
2026-05-18 11:27:49 +08:00
ecb3d3953b fix: rapikan import dan sesuaikan ukuran icon di notification
- Gabungkan import useState yang duplikat
- Kecilkan ukuran icon check-square dari 22 ke 20
2026-05-18 11:18:59 +08:00
9ca128a5ed Merge pull request 'refactor: pecah lib/api.ts dan constants/Styles.ts per domain' (#48) from amalia/12-mei-26 into join
Reviewed-on: #48
2026-05-12 17:25:26 +08:00
d299484a98 refactor: pecah lib/api.ts dan constants/Styles.ts per domain
- lib/api.ts (879 baris) → 13 file di lib/api/ per domain
- constants/Styles.ts (1.275 baris) → 10 file di constants/styles/ per domain
- tambah docs/FILE-HEALTH.md dan referensinya ke CLAUDE.md
- kedua file lama tetap sebagai re-export — zero breaking changes
2026-05-12 10:34:31 +08:00
003d92e4e3 Merge pull request 'amalia/11-mei-26' (#47) from amalia/11-mei-26 into join
Reviewed-on: #47
2026-05-11 17:34:52 +08:00
18f548ed5c chore: update Podfile.lock 2026-05-11 17:18:47 +08:00
906a619593 revert: kembalikan penyimpanan token ke AsyncStorage 2026-05-11 17:15:52 +08:00
af2048b4cd fix: ganti penyimpanan token dari AsyncStorage ke SecureStore
Token autentikasi kini disimpan di Keychain (iOS) / Keystore (Android)
sehingga otomatis terhapus saat uninstall dan tidak bisa dipulihkan
lewat Google Backup. @notification_permission tetap di AsyncStorage
karena bukan data sensitif.
2026-05-11 17:11:05 +08:00
7341f378dd feat: tambah sistem guide onboarding per fitur dengan GuideOverlay
- Buat komponen GuideOverlay dengan animasi fade+slide, arrow tooltip, dan dot indicator
- Buat hook useGuide untuk menyimpan state guide per fitur via AsyncStorage
- Sentralisasi semua step guide di lib/guideSteps.ts
- Pasang guide pada 12 halaman: village-calendar, project detail, banner, group, position, member, announcement, discussion, division calendar/document/discussion, dan division task detail
- Posisi card menggunakan cardTopRatio (rasio layar) untuk kompatibilitas berbagai ukuran device
- Tambah styles guide dan village calendar di constants/Styles.ts
2026-05-11 16:34:46 +08:00
84935e8188 feat: tambah fitur kalender umum village dengan indikator per jenis event
- Tambah halaman /village-calendar dengan monthly grid dan agenda view
- Tampilkan acara divisi (DivisionCalendarReminder) dan kegiatan (ProjectTask) se-village
- Indikator dot dua warna pada kalender: ungu untuk acara divisi, biru-abu untuk kegiatan
- Tambah endpoint apiGetVillageCalendarByDate dan apiGetVillageCalendarIndicator
- Tambah menu Kalender di halaman /feature dengan grid layout flexWrap
- Sesuaikan warna EventItem dengan TYPE_COLORS village-calendar
- Pindahkan inline style ke Styles.ts sebagai class baru
2026-05-11 15:19:21 +08:00
74d8b8ef31 Merge pull request 'amalia/08-mei-26' (#46) from amalia/08-mei-26 into join
Reviewed-on: #46
2026-05-11 10:17:25 +08:00
d31a21cc9c upd: redesign 2026-05-08 16:52:26 +08:00
4af54980a0 feat: tambah fitur tandai semua notifikasi dibaca dengan modal konfirmasi 2026-05-08 14:56:44 +08:00
4eebf2f893 feat: redesign halaman notifikasi dengan icon berwarna, grouping tanggal, dan urutan unread-first 2026-05-08 14:18:53 +08:00
bc2c89e030 feat: redesign halaman pencarian dengan filter tabs, section badge, dan card style 2026-05-08 11:37:21 +08:00
d96c954559 fix: tombol simpan edit anggota selalu disabled karena idGroup kosong 2026-05-08 10:34:28 +08:00
ca512372dd Merge pull request 'amalia/07-mei-26' (#45) from amalia/07-mei-26 into join
Reviewed-on: #45
2026-05-07 17:38:33 +08:00
d82f0c5b20 feat: redesign halaman detail user dan profile dengan tampilan modern
- Pindahkan badge APPROVER & AKTIF ke dalam header gradient
- Ganti card berlatar menjadi list dengan border bottom saja
- Gunakan icon colors.icon agar terlihat pada tema gelap
- Tambahkan class baru di Styles.ts: memberAvatarRing, memberBadgeRow,
  memberBadgeApprover, memberBadgePill, memberInfoRow, memberInfoIcon,
  memberInfoContent, cWhiteDimmed, pv14, mb08
- Terapkan design yang sama pada halaman profile
2026-05-07 16:45:52 +08:00
fad89fc910 feat: persist view mode (grid/list) across division, project, dan division/task
- Simpan preferensi tampilan ke AsyncStorage dengan key 'division_view_mode'
- Load preferensi saat halaman dibuka agar tidak reset
- Samakan style list item division/task dengan project (bg transparent, icon hitam)
- Sejajarkan toggle dengan input pencarian pada halaman division/task
2026-05-07 16:16:21 +08:00
e48456ea7f feat: tambah fitur approval task pada project dan divisi
- tambah komponen ModalRiwayatApproval dan ModalTolakApproval
- update itemSectionTanggalTugas untuk mendukung status menunggu persetujuan
- update sectionTanggalTugas (project) dan sectionTanggalTugasTask (divisi) dengan alur approval lengkap
- tambah API approval project task dan division task di lib/api.ts
- tambah toggle approver di headerMemberDetail dan tampilkan badge approver di detail member
- update carouselHome untuk dispatch isApprover ke Redux
- update drawerBottom untuk mendukung scroll pada modal
- ganti label 'Belum dimulai' menjadi 'Belum ada tugas yang diselesaikan'
2026-05-07 16:04:02 +08:00
d2e1663f9f Merge pull request 'amalia/06-mei-26' (#44) from amalia/06-mei-26 into join
Reviewed-on: #44
2026-05-06 17:15:40 +08:00
bdfb3a8b2b feat: redesign halaman create project dan task divisi dengan section card
- Ganti ButtonSelect dengan section card (Tanggal & Tugas, File, Anggota)
- Tiap card: header pressable dengan icon, badge count, chevron, dan preview isi
- Background item list (file & anggota) dibuat transparan (hanya border)
- Badge file seragam dengan badge tugas dan orang
- Tambah prop showTitle pada SectionListAddTask
- Ekstrak inline style ke Styles.ts: sectionActionRow, sectionBadge, positionBadge, listItemCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:10:08 +08:00
11bb1ddc98 feat: terapkan design baru pada halaman create project dan create task divisi
- Ganti file list (BorderBottomItem) dengan fileGrid/fileCard bergaya baru
  dengan icon berwarna sesuai tipe file
- Ganti member section dengan card individual per anggota (avatar + nama + badge jabatan)
- Header anggota: label kiri + jumlah orang di kanan
- Simpan field position saat memilih anggota di modalSelect
- Hapus wrapper wrapPaper di SectionListAddTask

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:50:54 +08:00
97726609e1 feat: redesign section member dan fix warna dimmed
- SectionMember & SectionMemberTask: ganti BorderBottomItem dengan card item
  custom (avatar + nama + badge jabatan), skeleton baru, label jumlah anggota
- Colors.ts: hapus suffix alpha '#707887ff' → '#707887' pada warna dimmed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:23:35 +08:00
b61cd51628 feat: redesign section progress, report, link, file, dan cancel pada project & division/task
- SectionProgress: progress bar animated, badge persentase, label status, task count
- SectionReport: header ikon, left accent border, TextExpandable dengan label Indonesia
- SectionLink: tap langsung buka URL, ikon per domain, long press untuk hapus
- SectionFile: icon container konsisten 30×30 di semua section
- SectionCancel: card subtle dengan warna error, konsisten dengan visual language baru
- TextExpandable: fix bug show/hide tidak muncul setelah content diupdate
- Tambah 14 style class baru di Styles.ts untuk menggantikan inline style
- Terapkan semua perubahan ke fitur division/task
- Fix menu "Edit Tugas" di sectionTanggalTugasTask yang terpotong karena overflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:22:52 +08:00
eccfe29387 feat: tambah fitur lampiran file pada tugas kegiatan dan tugas divisi 2026-05-06 12:32:33 +08:00
a090a85142 Merge pull request 'amalia/05-mei-26' (#43) from amalia/05-mei-26 into join
Reviewed-on: #43
2026-05-05 17:29:50 +08:00
bbacc2a5bd fix: sesuaikan warna panah navigasi kalender dengan tema gelap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 16:50:13 +08:00
5ad7874a92 fix: disable tombol detail jika tanggal mulai atau berakhir belum diisi
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 16:46:14 +08:00
a5e379cc7f fix: tampilkan ikon search dan ikon x secara bersamaan di input pencarian
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 16:26:07 +08:00
4d464adb2e Merge pull request 'upd: search' (#42) from amalia/04-mei-26 into join
Reviewed-on: #42
2026-05-04 17:56:53 +08:00
36c2519fa0 upd: search 2026-05-04 17:55:49 +08:00
e2ffef1085 Merge pull request 'amalia/23-apr-26' (#41) from amalia/23-apr-26 into join
Reviewed-on: #41
2026-04-23 17:32:41 +08:00
119 changed files with 7812 additions and 5137 deletions

View File

@@ -31,3 +31,7 @@ See @docs/ARCHITECTURE.md
## Key Conventions
See @docs/CONVENTIONS.md
## File Health
See @docs/FILE-HEALTH.md

View File

@@ -92,8 +92,8 @@ android {
applicationId 'mobiledarmasaba.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 16
versionName "2.1.0"
versionCode 17
versionName "2.2.0"
}
signingConfigs {
debug {

View File

@@ -4,7 +4,7 @@ export default {
expo: {
name: "Desa+",
slug: "mobile-darmasaba",
version: "2.1.0", // Versi aplikasi (App Store)
version: "2.2.0", // Versi aplikasi (App Store)
jsEngine: "jsc",
orientation: "portrait",
icon: "./assets/images/logo-icon-small.png",
@@ -14,7 +14,7 @@ export default {
ios: {
supportsTablet: true,
bundleIdentifier: "mobiledarmasaba.app",
buildNumber: "9",
buildNumber: "10",
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
CFBundleDisplayName: "Desa+"
@@ -23,7 +23,7 @@ export default {
},
android: {
package: "mobiledarmasaba.app",
versionCode: 16,
versionCode: 17,
adaptiveIcon: {
foregroundImage: "./assets/images/logo-icon-small.png",
backgroundColor: "#ffffff"

View File

@@ -1,6 +1,5 @@
import HeaderRightAnnouncementDetail from "@/components/announcement/headerAnnouncementDetail";
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import Skeleton from "@/components/skeleton";
import Text from '@/components/Text';
import ErrorView from "@/components/ErrorView";
@@ -10,7 +9,7 @@ import Styles from "@/constants/Styles";
import { apiGetAnnouncementOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Entypo, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as FileSystem from 'expo-file-system';
import { startActivityAsync } from 'expo-intent-launcher';
import { router, Stack, useLocalSearchParams } from "expo-router";
@@ -23,7 +22,6 @@ import RenderHTML from 'react-native-render-html';
import Toast from "react-native-toast-message";
import { useSelector } from "react-redux";
// Define TypeScript interfaces for better type safety
interface AnnouncementData {
id: string;
title: string;
@@ -59,26 +57,31 @@ export default function DetailAnnouncement() {
const [dataFile, setDataFile] = useState<FileData[]>([])
const update = useSelector((state: any) => state.announcementUpdate)
const entityUser = useSelector((state: any) => state.user)
const contentWidth = Dimensions.get('window').width
const contentWidth = Dimensions.get('window').width - 62
const [loading, setLoading] = useState(true)
const arrSkeleton = Array.from({ length: 2 }, (_, index) => index)
const [refreshing, setRefreshing] = useState(false)
const [loadingOpen, setLoadingOpen] = useState(false)
const [preview, setPreview] = useState(false)
const [chooseFile, setChooseFile] = useState<FileData>()
const [isError, setIsError] = useState(false)
/**
* Opens the image preview modal for the selected image file
* @param item The file data object containing image information
*/
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '18' },
iconBox: { backgroundColor: colors.icon + '18' },
sectionLabel: { color: colors.dimmed },
titleText: { color: colors.text },
fileChipBorder: { borderColor: colors.icon + '30' },
fileChipPressed: { backgroundColor: colors.icon + '10' },
groupSeparator: { borderTopColor: colors.icon + '18' },
divisionIconBg: { backgroundColor: colors.icon + '15' },
}
function handleChooseFile(item: FileData) {
setChooseFile(item)
setPreview(true)
}
async function handleLoad(loading: boolean) {
try {
setIsError(false)
@@ -97,39 +100,22 @@ export default function DetailAnnouncement() {
console.error(error);
setIsError(true)
const message = error?.response?.data?.message || "Gagal mengambil data"
Toast.show({ type: 'small', text1: message })
} finally {
setLoading(false)
}
}
useEffect(() => {
handleLoad(false)
}, [update])
useEffect(() => { handleLoad(false) }, [update])
useEffect(() => { handleLoad(true) }, [])
useEffect(() => {
handleLoad(true)
}, [])
/**
* Checks if a string contains HTML tags
* @param text The text to check for HTML tags
* @returns True if the text contains HTML tags, false otherwise
*/
function hasHtmlTags(text: string) {
const htmlRegex = /<[a-z][\s\S]*>/i;
return htmlRegex.test(text);
return /<[a-z][\s\S]*>/i.test(text);
}
/**
* Handles pull-to-refresh functionality
* Reloads the announcement data without showing loading indicators
*/
const handleRefresh = async () => {
setRefreshing(true)
handleLoad(false)
// Simulate network request delay for better UX
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false)
};
@@ -141,178 +127,171 @@ export default function DetailAnnouncement() {
const fileName = item.name + '.' + item.extension;
const localPath = `${FileSystem.documentDirectory}/${fileName}`;
const mimeType = mime.lookup(fileName);
// Download the file
const downloadResult = await FileSystem.downloadAsync(remoteUrl, localPath);
if (downloadResult.status !== 200) {
throw new Error(`Download failed with status ${downloadResult.status}`);
}
const contentURL = await FileSystem.getContentUriAsync(downloadResult.uri);
try {
if (Platform.OS === 'android') {
await startActivityAsync(
'android.intent.action.VIEW',
{
data: contentURL,
flags: 1,
type: mimeType as string,
}
);
await startActivityAsync('android.intent.action.VIEW', {
data: contentURL,
flags: 1,
type: mimeType as string,
});
} else if (Platform.OS === 'ios') {
await Sharing.shareAsync(localPath);
}
} catch (openError) {
console.error('Error opening file:', openError);
Toast.show({
type: 'error',
text1: 'Tidak ada aplikasi yang dapat membuka file ini'
});
} catch {
Toast.show({ type: 'error', text1: 'Tidak ada aplikasi yang dapat membuka file ini' });
}
} catch (error) {
console.error('Error downloading or opening file:', error);
Toast.show({
type: 'error',
text1: 'Gagal membuka file',
text2: 'Silakan coba lagi nanti'
});
} catch {
Toast.show({ type: 'error', text1: 'Gagal membuka file', text2: 'Silakan coba lagi nanti' });
} finally {
setLoadingOpen(false);
}
};
return (
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<SafeAreaView style={[Styles.flex1, themed.background]}>
<Stack.Screen
options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Pengumuman',
headerTitleAlign: 'center',
// headerRight: () => entityUser.role != 'user' && entityUser.role != 'coadmin' ? <HeaderRightAnnouncementDetail id={id} /> : <></>,
header: () => (
<AppHeader title="Pengumuman"
<AppHeader
title="Pengumuman"
showBack={true}
onPressLeft={() => router.back()}
right={entityUser.role != 'user' && entityUser.role != 'coadmin' ? <HeaderRightAnnouncementDetail id={id} /> : <></>}
right={entityUser.role != 'user' && entityUser.role != 'coadmin'
? <HeaderRightAnnouncementDetail id={id} />
: <></>
}
/>
)
}}
/>
<ScrollView
showsVerticalScrollIndicator={false}
style={[Styles.h100, { backgroundColor: colors.background }]}
style={[Styles.flex1, themed.background]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => handleRefresh()}
tintColor={colors.icon}
/>
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.icon} />
}
>
{isError && !loading ? (
<View style={[Styles.mv50]}>
<View style={Styles.mv50}>
<ErrorView />
</View>
) : (
<View style={[Styles.p15, Styles.mb50]}>
<View style={[Styles.wrapPaper, Styles.borderAll, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
loading ?
<View>
<View style={[Styles.rowOnly]}>
<Skeleton width={30} height={30} borderRadius={10} />
<View style={[Styles.flex1, Styles.ph05]}>
<Skeleton width={100} widthType="percent" height={30} borderRadius={10} />
</View>
</View>
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
</View>
:
<>
<View style={[Styles.rowOnly, Styles.alignStart]}>
<MaterialIcons name="campaign" size={25} color={colors.text} style={[Styles.mr05]} />
<Text style={[Styles.textDefaultSemiBold, Styles.w90, Styles.mt02]}>{data?.title}</Text>
</View>
<View style={[Styles.mt10]}>
{
hasHtmlTags(data?.desc) ?
<RenderHTML
contentWidth={contentWidth}
source={{ html: data?.desc }}
baseStyle={{ color: colors.text }}
/>
:
<Text>{data?.desc}</Text>
}
</View>
</>
}
<View style={Styles.announcementDetailContainer}>
</View>
{
dataFile.length > 0 && (
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
<View style={[Styles.mb05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
{/* Title + Description */}
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, themed.card]}>
{loading ? (
<View style={Styles.announcementDetailSkeletonGap}>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSkeletonIconRow]}>
<Skeleton width={38} height={38} borderRadius={10} />
<Skeleton width={60} widthType="percent" height={16} borderRadius={6} />
</View>
{dataFile.map((item, index) => (
<BorderBottomItem
key={`${item.id}-${index}`}
borderType={index === dataFile.length - 1 ? 'none' : 'bottom'}
icon={<MaterialCommunityIcons
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={25}
color={colors.text}
/>}
title={item.name + '.' + item.extension}
titleWeight="normal"
onPress={() => {
isImageFile(item.extension) ?
handleChooseFile(item)
: openFile(item)
}}
/>
))}
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={80} widthType="percent" height={10} borderRadius={6} />
</View>
)
}
<View style={[Styles.wrapPaper, Styles.borderAll, Styles.mt10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<View key={index}>
<Skeleton width={30} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={10} />
</View>
)
})
:
Object.keys(dataMember).map((v: any, i: any) => {
return (
<View key={i} style={[Styles.mb05]}>
<Text style={[Styles.textDefaultSemiBold]}>{dataMember[v]?.[0].group}</Text>
{
dataMember[v].map((item: any, x: any) => {
return (
<View key={x} style={[Styles.rowItemsCenter, Styles.w90]}>
<Entypo name="dot-single" size={24} color={colors.text} />
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{item.division}</Text>
</View>
)
})
}
</View>
)
})
}
) : (
<>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailTitleRow]}>
<View style={[Styles.sectionIconBox, Styles.announcementDetailIconBox, themed.iconBox]}>
<MaterialIcons name="campaign" size={22} color={colors.icon} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.announcementDetailTitleText, themed.titleText]} numberOfLines={2}>
{data.title}
</Text>
</View>
{hasHtmlTags(data.desc)
? <RenderHTML
contentWidth={contentWidth}
source={{ html: data.desc }}
baseStyle={{ color: colors.text }}
/>
: <Text style={Styles.textDefault}>{data.desc}</Text>
}
</>
)}
</View>
{/* Files */}
{dataFile.length > 0 && (
<View>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSectionLabelRow]}>
<MaterialCommunityIcons name="paperclip" size={14} color={colors.dimmed} />
<Text style={[Styles.textInformation, themed.sectionLabel]}>
Lampiran ({dataFile.length})
</Text>
</View>
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, Styles.announcementDetailFileCardPadding, themed.card]}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailFileChipList]}>
{dataFile.map((item, index) => (
<Pressable
key={`${item.id}-${index}`}
onPress={() => isImageFile(item.extension) ? handleChooseFile(item) : openFile(item)}
style={({ pressed }) => [Styles.announcementDetailFileChip, themed.fileChipBorder,
pressed ? themed.fileChipPressed : themed.background]}
>
<MaterialCommunityIcons
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
size={16}
color={colors.icon}
/>
<Text style={[Styles.textInformation, Styles.announcementDetailFileChipText, themed.titleText]} numberOfLines={1}>
{item.name}.{item.extension}
</Text>
</Pressable>
))}
</View>
</ScrollView>
</View>
</View>
)}
{/* Recipients */}
<View>
<View style={[Styles.rowItemsCenter, Styles.announcementDetailSectionLabelRow]}>
<MaterialIcons name="groups" size={14} color={colors.dimmed} />
<Text style={[Styles.textInformation, themed.sectionLabel]}>
Ditujukan Kepada
</Text>
</View>
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.sectionCard, Styles.announcementDetailCard, themed.card]}>
{loading ? (
<View style={Styles.announcementDetailRecipientGap}>
<Skeleton width={40} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={60} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
</View>
) : (
Object.keys(dataMember).map((v, i) => (
<View key={i} style={i > 0 ? [Styles.announcementDetailGroupSeparator, themed.groupSeparator] : undefined}>
<Text style={[Styles.textInformation, Styles.announcementDetailGroupLabel, themed.sectionLabel]}>
{dataMember[v]?.[0].group}
</Text>
<View>
{dataMember[v].map((item, x) => (
<View key={x} style={[Styles.rowItemsCenter, Styles.announcementDetailDivisionRow]}>
<View style={[Styles.announcementDetailDivisionIconCircle, themed.divisionIconBg]}>
<MaterialIcons name="group" size={14} color={colors.icon} />
</View>
<Text style={[Styles.textDefault, Styles.flex1, themed.titleText]}>
{item.division}
</Text>
</View>
))}
</View>
</View>
))
)}
</View>
</View>
</View>
)}
</ScrollView>
@@ -323,38 +302,26 @@ export default function DetailAnnouncement() {
visible={preview}
onRequestClose={() => setPreview(false)}
doubleTapToZoomEnabled
HeaderComponent={({ imageIndex }) => (
<View style={[Styles.headerModalViewImg]}>
{/* CLOSE */}
<Pressable
onPress={() => setPreview(false)}
accessibilityRole="button"
accessibilityLabel="Close image viewer"
>
HeaderComponent={() => (
<View style={Styles.headerModalViewImg}>
<Pressable onPress={() => setPreview(false)} accessibilityRole="button">
<Text style={[Styles.textWhite, Styles.font26]}></Text>
</Pressable>
{/* MENU */}
<Pressable
onPress={() => chooseFile && openFile(chooseFile)}
accessibilityRole="button"
accessibilityLabel="Download or share image"
disabled={loadingOpen}
>
<Text style={[{ color: loadingOpen ? 'gray' : 'white' }, Styles.font26]}></Text>
<Text style={[Styles.font26, { color: loadingOpen ? 'gray' : 'white' }]}></Text>
</Pressable>
</View>
)}
FooterComponent={({ imageIndex }) => (
<View style={[
Styles.pb20,
Styles.ph16,
Styles.alignCenter,
]}>
FooterComponent={() => (
<View style={[Styles.pb20, Styles.ph16, Styles.alignCenter]}>
<Text style={[Styles.textWhite, Styles.font16]}>{chooseFile?.name}.{chooseFile?.extension}</Text>
</View>
)}
/>
</SafeAreaView>
)
}
}

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
@@ -13,14 +11,34 @@ import { setUpdateAnnouncement } from "@/lib/announcementUpdate";
import { apiCreateAnnouncement } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Entypo, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack } from "expo-router";
import React, { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function CreateAnnouncement() {
const dispatch = useDispatch()
const update = useSelector((state: any) => state.announcementUpdate)
@@ -28,109 +46,77 @@ export default function CreateAnnouncement() {
const { colors } = useTheme();
const [disableBtn, setDisableBtn] = useState(true);
const [modalDivisi, setModalDivisi] = useState(false);
const [divisionMember, setDivisionMember] = useState<any>([])
const [divisionMember, setDivisionMember] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [fileForm, setFileForm] = useState<any[]>([])
const [isModalFile, setModalFile] = useState(false)
const [indexDelFile, setIndexDelFile] = useState<number>(0)
const [dataForm, setDataForm] = useState({
title: "",
desc: "",
});
const [error, setError] = useState({
title: false,
desc: false,
});
const [dataForm, setDataForm] = useState({ title: "", desc: "" });
const [error, setError] = useState({ title: false, desc: false });
const totalDivisi = divisionMember.reduce((acc: number, g: any) => acc + g.Division.length, 0)
function validationForm(cat: string, val: any) {
if (cat == "title") {
if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (
Object.values(error).some((v) => v == true) ||
Object.values(dataForm).some((v) => v == "")
) {
setDisableBtn(true);
} else {
setDisableBtn(false);
}
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => {
checkForm();
}, [error, dataForm]);
useEffect(() => { checkForm() }, [error, dataForm]);
async function handleCreate() {
try {
setLoading(true)
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{ user: hasil, groups: divisionMember, ...dataForm }
))
fd.append("data", JSON.stringify({ user: hasil, groups: divisionMember, ...dataForm }))
const response = await apiCreateAnnouncement(fd)
if (response.success) {
dispatch(setUpdateAnnouncement(!update))
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', })
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data' })
router.back();
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Tidak dapat terhubung ke server"
Toast.show({
type: 'small',
text1: message
})
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Tidak dapat terhubung ke server" })
} finally {
setLoading(false)
}
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
if (fileForm.some(f => f.name === asset.name)) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number) {
setFileForm([...fileForm.filter((val, i) => i !== index)])
setFileForm(fileForm.filter((_, i) => i !== index))
setModalFile(false)
}
@@ -138,26 +124,6 @@ export default function CreateAnnouncement() {
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Tambah Pengumuman",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={disableBtn || divisionMember.length == 0 || loading ? true : false}
// category="create"
// onPress={() => {
// divisionMember.length == 0
// ? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
// : handleCreate();
// }}
// />
// ),
header: () => (
<AppHeader
title="Tambah Pengumuman"
@@ -165,12 +131,12 @@ export default function CreateAnnouncement() {
onPressLeft={() => router.back()}
right={
<ButtonSaveHeader
disable={disableBtn || divisionMember.length == 0 || loading ? true : false}
disable={disableBtn || divisionMember.length === 0 || loading}
category="create"
onPress={() => {
divisionMember.length == 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
: handleCreate();
divisionMember.length === 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi" })
: handleCreate()
}}
/>
}
@@ -179,11 +145,9 @@ export default function CreateAnnouncement() {
}}
/>
{loading && <LoadingCenter />}
<ScrollView
showsVerticalScrollIndicator={false}
style={[Styles.h100, { backgroundColor: colors.background }]}
>
<View style={[Styles.p15]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, { backgroundColor: colors.background }]}>
<View style={Styles.p15}>
<InputForm
label="Judul"
type="default"
@@ -194,6 +158,7 @@ export default function CreateAnnouncement() {
errorText="Judul harus diisi"
onChange={(val) => validationForm("title", val)}
/>
<InputForm
label="Pengumuman"
type="default"
@@ -205,68 +170,105 @@ export default function CreateAnnouncement() {
onChange={(val) => validationForm("desc", val)}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
fileForm.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: fileForm.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
fileForm.map((item, index) => (
<BorderBottomItem
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{fileForm.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{fileForm.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{fileForm.length} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{fileForm.length > 0 && (
<View style={Styles.fileGrid}>
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
bgColor="transparent"
titleWeight="normal"
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
/>
))
}
</View>
</>
}
<ButtonSelect
value="Pilih divisi penerima pengumuman"
onPress={() => {
setModalDivisi(true)
}}
/>
{
divisionMember.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Divisi</Text>
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
divisionMember.map((item: { name: any; Division: any }, index: any) => {
return (
<View key={index}>
<Text style={[Styles.textDefaultSemiBold]}>{item.name}</Text>
{
item.Division.map((division: any, i: any) => (
<View key={i} style={[Styles.rowItemsCenter, Styles.w90]}>
<Entypo name="dot-single" size={24} color={colors.text} />
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{division.name}</Text>
</View>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
)
})
}
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
{/* Divisi Penerima */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={() => setModalDivisi(true)}
style={[Styles.sectionActionRow, { marginBottom: divisionMember.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialIcons name="groups" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Divisi Penerima</Text>
{divisionMember.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada divisi dipilih</Text>
)}
</View>
{divisionMember.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{totalDivisi} divisi</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{divisionMember.length > 0 && (
<View style={{ gap: 10 }}>
{divisionMember.map((item: any, index: number) => (
<View key={index}>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed, marginBottom: 4 }]}>
{item.name}
</Text>
<View style={{ gap: 6 }}>
{item.Division.map((division: any, i: number) => (
<View key={i} style={[Styles.listItemCard, { borderColor: colors.icon + '18' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18', width: 28, height: 28, borderRadius: 8 }]}>
<MaterialIcons name="group" size={14} color={colors.tabActive} />
</View>
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{division.name}
</Text>
</View>
))}
</View>
</View>
))}
</View>
)}
</View>
</View>
</ScrollView>
@@ -287,12 +289,10 @@ export default function CreateAnnouncement() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile) }}
onPress={() => deleteFile(indexDelFile)}
/>
</View>
</DrawerBottom>
</SafeAreaView>
);
}

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
@@ -13,22 +11,38 @@ import { setUpdateAnnouncement } from "@/lib/announcementUpdate";
import { apiEditAnnouncement, apiGetAnnouncementOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Entypo, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
type GroupDivision = {
id: string;
name: string;
Division: {
id: string;
name: string;
}[];
Division: { id: string; name: string }[];
}
export default function EditAnnouncement() {
@@ -39,43 +53,29 @@ export default function EditAnnouncement() {
const { colors } = useTheme();
const [modalDivisi, setModalDivisi] = useState(false);
const [disableBtn, setDisableBtn] = useState(true);
const [dataMember, setDataMember] = useState<any>([]);
const [dataMember, setDataMember] = useState<GroupDivision[]>([]);
const [fileForm, setFileForm] = useState<any[]>([])
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string; delete?: boolean }[]>([])
const [indexDelFile, setIndexDelFile] = useState<{ id: string | number; cat: "newFile" | "oldFile" }>({ id: "", cat: "newFile" })
const [isModalFile, setModalFile] = useState(false)
const [loading, setLoading] = useState(false)
const [dataForm, setDataForm] = useState({
title: "",
desc: "",
});
const [error, setError] = useState({
title: false,
desc: false,
});
const [dataForm, setDataForm] = useState({ title: "", desc: "" });
const [error, setError] = useState({ title: false, desc: false });
const visibleOldFiles = dataFile.filter(v => !v.delete)
const totalFiles = fileForm.length + visibleOldFiles.length
const totalDivisi = dataMember.reduce((acc: number, g: any) => acc + g.Division.length, 0)
async function handleLoad() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetAnnouncementOne({ id: id, user: hasil });
setDataForm(response.data);
const arrNew: GroupDivision[] = []
const coba = Object.keys(response.member).map((v: any, i: any) => {
const newObject = {
"id": response.member[v][0].idGroup,
"name": v,
"Division": response.member[v]
}
response.member[v].map((v: any, i: any) => {
newObject["Division"][i] = {
"id": v.idDivision,
"name": v.division
}
})
arrNew.push(newObject)
})
const arrNew: GroupDivision[] = Object.keys(response.member).map((v) => ({
id: response.member[v][0].idGroup,
name: v,
Division: response.member[v].map((m: any) => ({ id: m.idDivision, name: m.division }))
}))
setDataMember(arrNew);
setDataFile(response.file);
} catch (error) {
@@ -83,42 +83,25 @@ export default function EditAnnouncement() {
}
}
useEffect(() => {
handleLoad();
}, []);
useEffect(() => { handleLoad() }, []);
function validationForm(cat: string, val: any) {
if (cat == "title") {
if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (
Object.values(error).some((v) => v == true) ||
Object.values(dataForm).some((v) => v == "")
) {
setDisableBtn(true);
} else {
setDisableBtn(false);
}
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => {
checkForm();
}, [error, dataForm]);
useEffect(() => { checkForm() }, [error, dataForm]);
async function handleEdit() {
try {
@@ -126,90 +109,56 @@ export default function EditAnnouncement() {
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{
...dataForm, user: hasil, groups: dataMember, oldFile: dataFile
}
))
fd.append("data", JSON.stringify({ ...dataForm, user: hasil, groups: dataMember, oldFile: dataFile }))
const response = await apiEditAnnouncement(fd, id);
if (response.success) {
dispatch(setUpdateAnnouncement(!update))
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', })
Toast.show({ type: 'small', text1: 'Berhasil mengubah data' })
router.back();
} else {
Toast.show({ type: 'small', text1: 'Gagal mengubah data', })
Toast.show({ type: 'small', text1: 'Gagal mengubah data' })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengubah data" })
} finally {
setLoading(false)
}
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
const isDup = fileForm.some(f => f.name === asset.name) ||
visibleOldFiles.some(f => `${f.name}.${f.extension}` === asset.name)
if (isDup) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat == "newFile") {
setFileForm([...fileForm.filter((val, i) => i !== index)])
if (cat === "newFile") {
setFileForm(fileForm.filter((_, i) => i !== index))
} else {
setDataFile(prev =>
prev.map(item =>
item.id === index
? { ...item, delete: true }
: item
)
);
setDataFile(prev => prev.map(item => item.id === index ? { ...item, delete: true } : item))
}
setModalFile(false)
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Edit Pengumuman",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={disableBtn || loading ? true : false}
// category="update"
// onPress={() => {
// dataMember.length == 0
// ? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
// : handleEdit();
// }}
// />
// ),
header: () => (
<AppHeader
title="Edit Pengumuman"
@@ -217,12 +166,12 @@ export default function EditAnnouncement() {
onPressLeft={() => router.back()}
right={
<ButtonSaveHeader
disable={disableBtn || loading ? true : false}
disable={disableBtn || dataMember.length === 0 || loading}
category="update"
onPress={() => {
dataMember.length == 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi", })
: handleEdit();
dataMember.length === 0
? Toast.show({ type: 'small', text1: "Anda belum memilih divisi" })
: handleEdit()
}}
/>
}
@@ -231,11 +180,9 @@ export default function EditAnnouncement() {
}}
/>
{loading && <LoadingCenter />}
<ScrollView
showsVerticalScrollIndicator={false}
style={[Styles.h100, { backgroundColor: colors.background }]}
>
<View style={[Styles.p15]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, { backgroundColor: colors.background }]}>
<View style={Styles.p15}>
<InputForm
label="Judul"
type="default"
@@ -247,6 +194,7 @@ export default function EditAnnouncement() {
onChange={(val) => validationForm("title", val)}
value={dataForm.title}
/>
<InputForm
label="Pengumuman"
type="default"
@@ -259,79 +207,125 @@ export default function EditAnnouncement() {
value={dataForm.desc}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
(fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0)
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length + dataFile.filter((val) => !val.delete).length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: totalFiles > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataFile.filter((val) => !val.delete).map((item, index) => (
<BorderBottomItem
key={index}
borderType={dataFile.filter((val) => !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + '.' + item.extension}
titleWeight="normal"
bgColor="transparent"
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{totalFiles === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{totalFiles > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{totalFiles} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{totalFiles > 0 && (
<View style={Styles.fileGrid}>
{visibleOldFiles.map((item, index) => {
const ext = item.extension.toLowerCase()
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`old-${index}`}
onPress={() => { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }}
/>
))
}
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
bgColor="transparent"
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
/>
))
}
</View>
</>
}
<ButtonSelect
value="Pilih divisi penerima pengumuman"
onPress={() => {
setModalDivisi(true)
}}
/>
{
dataMember.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Divisi</Text>
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataMember.map((item: { name: any; Division: any }, index: any) => {
return (
<View key={index}>
<Text style={[Styles.textDefaultSemiBold]}>{item.name}</Text>
{
item.Division.map((division: any, i: any) => (
<View key={i} style={[Styles.rowItemsCenter, Styles.w90]}>
<Entypo name="dot-single" size={24} color={colors.text} />
<Text style={[Styles.textDefault]} numberOfLines={1} ellipsizeMode='tail'>{division.name}</Text>
</View>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
)
})
}
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`new-${index}`}
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
{/* Divisi Penerima */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={() => setModalDivisi(true)}
style={[Styles.sectionActionRow, { marginBottom: dataMember.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialIcons name="groups" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Divisi Penerima</Text>
{dataMember.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada divisi dipilih</Text>
)}
</View>
{dataMember.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{totalDivisi} divisi</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{dataMember.length > 0 && (
<View style={{ gap: 10 }}>
{dataMember.map((item, index) => (
<View key={index}>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed, marginBottom: 4 }]}>
{item.name}
</Text>
<View style={{ gap: 6 }}>
{item.Division.map((division, i) => (
<View key={i} style={[Styles.listItemCard, { borderColor: colors.icon + '18' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18', width: 28, height: 28, borderRadius: 8 }]}>
<MaterialIcons name="group" size={14} color={colors.tabActive} />
</View>
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{division.name}
</Text>
</View>
))}
</View>
</View>
))}
</View>
)}
</View>
</View>
</ScrollView>
@@ -353,7 +347,7 @@ export default function EditAnnouncement() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }}
onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)}
/>
</View>
</DrawerBottom>

View File

@@ -1,22 +1,24 @@
import BorderBottomItem from "@/components/borderBottomItem";
import GuideOverlay from "@/components/GuideOverlay";
import InputSearch from "@/components/inputSearch";
import SkeletonContent from "@/components/skeletonContent";
import Skeleton from "@/components/skeleton";
import Text from '@/components/Text';
import Styles from "@/constants/Styles";
import { apiGetAnnouncement } from "@/lib/api";
import { GUIDE_ANNOUNCEMENT } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { MaterialIcons } from "@expo/vector-icons";
import { useInfiniteQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { RefreshControl, View, VirtualizedList } from "react-native";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pressable, RefreshControl, View, VirtualizedList } from "react-native";
import { useSelector } from "react-redux";
type Props = {
id: string,
title: string,
desc: string,
id: string
title: string
desc: string
createdAt: string
}
@@ -25,9 +27,20 @@ export default function Announcement() {
const { colors } = useTheme();
const [search, setSearch] = useState('')
const update = useSelector((state: any) => state.announcementUpdate)
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
const isFirstRender = useRef(true)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('announcement')
const arrSkeleton = Array.from({ length: 5 }, (_, i) => i)
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '18' },
iconBox: { backgroundColor: colors.icon + '18' },
title: { color: colors.text },
desc: { color: colors.dimmed },
date: { color: colors.dimmed },
cardPressed: { backgroundColor: colors.icon + '08' },
}
// TanStack Query Infinite Query
const {
data,
fetchNextPage,
@@ -40,11 +53,7 @@ export default function Announcement() {
queryKey: ['announcements', search],
queryFn: async ({ pageParam = 1 }) => {
const hasil = await decryptToken(String(token?.current))
const response = await apiGetAnnouncement({
user: hasil,
search: search,
page: pageParam
})
const response = await apiGetAnnouncement({ user: hasil, search, page: pageParam })
return response.data
},
initialPageParam: 1,
@@ -53,21 +62,12 @@ export default function Announcement() {
},
})
// Trigger refetch when Redux state 'update' changes
useEffect(() => {
if (isFirstRender.current) { isFirstRender.current = false; return }
refetch()
}, [update, refetch])
}, [update])
// Flatten data from pages
const flattenedData = useMemo(() => {
return data?.pages.flat() || []
}, [data])
const loadMoreData = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
};
const flattenedData = useMemo(() => data?.pages.flat() || [], [data])
const getItem = (_data: unknown, index: number): Props => ({
id: flattenedData[index].id,
@@ -76,58 +76,80 @@ export default function Announcement() {
createdAt: flattenedData[index].createdAt,
})
return (
<View style={[Styles.p15, Styles.flex1, { backgroundColor: colors.background }]}>
<View>
<InputSearch onChange={setSearch} />
const renderSkeleton = () => (
<View style={Styles.announcementListSkeletonCard}>
<View style={[Styles.rowSpaceBetween, Styles.rowItemsCenter, Styles.announcementListSkeletonHeader]}>
<View style={[Styles.rowItemsCenter, Styles.announcementListSkeletonTitleRow]}>
<Skeleton width={28} height={28} borderRadius={8} />
<Skeleton width={50} widthType="percent" height={12} borderRadius={6} />
</View>
<Skeleton width={15} widthType="percent" height={10} borderRadius={6} />
</View>
<View style={[Styles.flex2, Styles.mt05]}>
{
isLoading && !flattenedData.length ?
arrSkeleton.map((item, index) => {
return (
<SkeletonContent key={index} />
)
})
:
flattenedData.length > 0
?
<VirtualizedList
data={flattenedData}
getItemCount={() => flattenedData.length}
getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => {
return (
<BorderBottomItem
key={index}
onPress={() => { router.push(`/announcement/${item.id}`) }}
borderType="bottom"
bgColor="transparent"
icon={
<MaterialIcons name="campaign" size={25} color={colors.text} />
}
title={item.title}
desc={item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')}
rightTopInfo={item.createdAt}
/>
)
}}
keyExtractor={(item, index) => String(item.id || index)}
onEndReached={loadMoreData}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefetching && !isFetchingNextPage}
onRefresh={refetch}
tintColor={colors.icon}
/>
}
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={80} widthType="percent" height={10} borderRadius={6} />
</View>
)
const renderItem = ({ item }: { item: Props }) => (
<Pressable
onPress={() => router.push(`/announcement/${item.id}`)}
style={({ pressed }) => [Styles.announcementListCard, themed.card, pressed && themed.cardPressed]}
>
<View style={[Styles.rowSpaceBetween, Styles.rowItemsCenter, Styles.announcementListCardHeader]}>
<View style={[Styles.rowItemsCenter, Styles.announcementListTitleRow]}>
<View style={[Styles.announcementListIconBox, themed.iconBox]}>
<MaterialIcons name="campaign" size={16} color={colors.icon} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.announcementListTitleText, themed.title]} numberOfLines={1}>
{item.title}
</Text>
</View>
<Text style={[Styles.textInformation, Styles.announcementListDateText, themed.date]}>
{item.createdAt}
</Text>
</View>
<Text style={[Styles.textMediumNormal, Styles.announcementListDescText, themed.title]} numberOfLines={2}>
{item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')}
</Text>
</Pressable>
)
return (
<View style={[Styles.flex1, Styles.announcementListContainer, themed.background]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_ANNOUNCEMENT} onDismiss={dismissGuide} />
<InputSearch onChange={setSearch} />
<View style={[Styles.flex1, Styles.announcementListInner]}>
{isLoading && !flattenedData.length ? (
arrSkeleton.map((_, i) => (
<View key={i} style={[Styles.announcementListCard, themed.card]}>
{renderSkeleton()}
</View>
))
) : flattenedData.length > 0 ? (
<VirtualizedList
data={flattenedData}
getItemCount={() => flattenedData.length}
getItem={getItem}
renderItem={renderItem}
keyExtractor={(item, index) => String(item.id || index)}
onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage() }}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={Styles.announcementListSeparator} />}
refreshControl={
<RefreshControl
refreshing={isRefetching && !isFetchingNextPage}
onRefresh={refetch}
tintColor={colors.icon}
/>
:
<Text style={[Styles.textDefault, Styles.textCenter, { color: colors.dimmed }]}>Tidak ada pengumuman</Text>
}
}
/>
) : (
<Text style={[Styles.textDefault, Styles.textCenter, Styles.mt30, themed.desc]}>
Tidak ada pengumuman
</Text>
)}
</View>
</View>
)
}
}

View File

@@ -1,5 +1,5 @@
import styles from "@/components/AppHeader"
import AppHeader from "@/components/AppHeader"
import GuideOverlay from "@/components/GuideOverlay"
import HeaderRightBannerList from "@/components/banner/headerBannerList"
import BorderBottomItem from "@/components/borderBottomItem"
import DrawerBottom from "@/components/drawerBottom"
@@ -12,6 +12,8 @@ import { ConstEnv } from "@/constants/ConstEnv"
import Styles from "@/constants/Styles"
import { apiDeleteBanner, apiGetBanner } from "@/lib/api"
import { setEntities } from "@/lib/bannerSlice"
import { GUIDE_BANNER } from "@/lib/guideSteps"
import { useGuide } from "@/lib/useGuide"
import { useAuthSession } from "@/providers/AuthProvider"
import { useTheme } from "@/providers/ThemeProvider"
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"
@@ -44,6 +46,7 @@ export default function BannerList() {
const dispatch = useDispatch()
const [refreshing, setRefreshing] = useState(false)
const [loadingOpen, setLoadingOpen] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('banner')
const [viewImg, setViewImg] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const queryClient = useQueryClient()
@@ -147,6 +150,7 @@ export default function BannerList() {
)
}}
/>
<GuideOverlay visible={guideVisible} steps={GUIDE_BANNER} onDismiss={dismissGuide} />
<ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} />
<ScrollView
refreshControl={

View File

@@ -1,11 +1,9 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import BorderBottomItem2 from "@/components/borderBottomItem2";
import HeaderRightDiscussionGeneralDetail from "@/components/discussion_general/headerDiscussionDetail";
import DrawerBottom from "@/components/drawerBottom";
import ImageUser from "@/components/imageNew";
import { InputForm } from "@/components/inputForm";
import LabelStatus from "@/components/labelStatus";
import MenuItemRow from "@/components/menuItemRow";
import ModalConfirmation from "@/components/ModalConfirmation";
import Skeleton from "@/components/skeleton";
@@ -271,29 +269,36 @@ export default function DetailDiscussionGeneral() {
borderType="all"
bgColor="white"
icon={
<View style={[Styles.iconContent]}>
<MaterialIcons name="chat" size={25} color={'black'} />
<View style={[Styles.discussionIconCircleLg, { backgroundColor: colors.icon + '20' }]}>
<Feather name="message-circle" size={22} color={colors.icon} />
</View>
}
title={data?.title}
titleShowAll={true}
subtitle={
!data?.isActive ?
<LabelStatus category='warning' text='ARSIP' size="small" />
:
<LabelStatus category={data.status == 1 ? 'success' : 'error'} text={data.status == 1 ? 'BUKA' : 'TUTUP'} size="small" />
<View style={[Styles.discussionStatusPill, {
borderColor: !data?.isActive
? '#F59E0B'
: data?.status == 1 ? '#10B981' : colors.dimmed + '80',
}]}>
<Text style={[Styles.discussionStatusText, {
color: !data?.isActive
? '#F59E0B'
: data?.status == 1 ? '#10B981' : colors.dimmed,
}]}>
{!data?.isActive ? 'Arsip' : data?.status == 1 ? 'Buka' : 'Tutup'}
</Text>
</View>
}
desc={data?.desc}
leftBottomInfo={
<View style={[Styles.rowItemsCenter]}>
<Ionicons name="chatbox-ellipses-outline" size={18} color={colors.dimmed} style={Styles.mr05} />
<Text style={[Styles.textInformation, { color: colors.dimmed }, Styles.mb05]}>{dataKomentar.length} Komentar</Text>
<Feather name="message-square" size={14} color={colors.dimmed} style={Styles.mr05} />
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>{dataKomentar.length} Komentar</Text>
</View>
}
rightBottomInfo={
<View style={[Styles.rowItemsCenter]}>
<Text style={[Styles.textInformation, { color: colors.dimmed }, Styles.mb05]}>{data?.createdAt}</Text>
</View>
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>{data?.createdAt}</Text>
}
/>
}
@@ -306,36 +311,56 @@ export default function DetailDiscussionGeneral() {
)
})
:
dataKomentar.map((item, i) => {
return (
<BorderBottomItem
key={i}
borderType="all"
colorPress
icon={
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
dataKomentar.map((item, i) => (
<Pressable
key={i}
onPress={() => {
setDetailMore((prev: any) =>
prev.includes(item.id)
? prev.filter((id: string) => id !== item.id)
: [...prev, item.id]
)
}}
onLongPress={() => {
item.idUser == entities.id && data?.status != 2 && data?.isActive && handleMenuKomentar(item.id, item.comment)
}}
style={({ pressed }) => [
Styles.discussionCommentCard,
{
backgroundColor: pressed ? colors.icon + '10' : colors.card,
borderColor: colors.icon + '20',
}
title={item.username}
rightTopInfo={item.createdAt}
desc={item.comment}
rightBottomInfo={item.isEdited ? "Edited" : ""}
descEllipsize={detailMore.includes(item.id) ? false : true}
bgColor="white"
onPress={() => {
setDetailMore((prev: any) => {
if (prev.includes(item.id)) {
return prev.filter((id: string) => id !== item.id)
} else {
return [...prev, item.id]
}
})
}}
onLongPress={() => {
item.idUser == entities.id && data?.status != 2 && data?.isActive && handleMenuKomentar(item.id, item.comment)
}}
/>
)
})
]}
>
<View style={Styles.flex1}>
{/* Name + time */}
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter, Styles.mb05]}>
<View style={[Styles.rowItemsCenter, { gap: 8, flex: 1, marginRight: 8 }]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textMediumSemiBold, { color: colors.text }]} numberOfLines={1}>
{item.username}
</Text>
{item.isEdited && (
<Text style={[Styles.discussionEditedText, { color: colors.dimmed }]}>
diedit
</Text>
)}
</View>
<Text style={[Styles.discussionDateText, { color: colors.dimmed, flexShrink: 0 }]}>
{item.createdAt}
</Text>
</View>
{/* Comment text */}
<Text
style={[Styles.textDefault, { color: colors.text }]}
numberOfLines={detailMore.includes(item.id) ? 0 : 3}
>
{item.comment}
</Text>
</View>
</Pressable>
))
}
</View>
</View>
@@ -372,15 +397,14 @@ export default function DetailDiscussionGeneral() {
multiline
focus={viewEdit}
itemRight={
<Pressable onPress={() => {
(!loadingSendKomentar && selectKomentar.comment != '' && !regexOnlySpacesOrEnter.test(selectKomentar.comment) && data?.status === 1 && data?.isActive && (memberDiscussion || (entityUser.role != "user" && entityUser.role != "coadmin")))
&& handleEditKomentar()
}}
style={[
Platform.OS == 'android' && Styles.mb12,
]}
<Pressable
onPress={() => {
(!loadingSendKomentar && selectKomentar.comment != '' && !regexOnlySpacesOrEnter.test(selectKomentar.comment) && data?.status === 1 && data?.isActive && (memberDiscussion || (entityUser.role != "user" && entityUser.role != "coadmin")))
&& handleEditKomentar()
}}
style={[Platform.OS == 'android' && Styles.mb12]}
>
<MaterialIcons name="send" size={25} style={(loadingSendKomentar || selectKomentar.comment == '' || regexOnlySpacesOrEnter.test(selectKomentar.comment) || data?.status === 2 || !data?.isActive || (!memberDiscussion && (entityUser.role == "user" || entityUser.role == "coadmin"))) ? { color: colors.dimmed } : { color: colors.tint }} />
<MaterialIcons name="send" size={25} style={(loadingSendKomentar || selectKomentar.comment == '' || regexOnlySpacesOrEnter.test(selectKomentar.comment) || data?.status === 2 || !data?.isActive || (!memberDiscussion && (entityUser.role == "user" || entityUser.role == "coadmin"))) ? { color: colors.dimmed } : { color: colors.tint }} />
</Pressable>
}
/>
@@ -398,15 +422,14 @@ export default function DetailDiscussionGeneral() {
multiline
focus={viewEdit}
itemRight={
<Pressable onPress={() => {
(!loadingSendKomentar && komentar != '' && !regexOnlySpacesOrEnter.test(komentar) && data?.status === 1 && data?.isActive && (memberDiscussion || (entityUser.role != "user" && entityUser.role != "coadmin")))
&& handleKomentar()
}}
style={[
Platform.OS == 'android' && Styles.mb12,
]}
<Pressable
onPress={() => {
(!loadingSendKomentar && komentar != '' && !regexOnlySpacesOrEnter.test(komentar) && data?.status === 1 && data?.isActive && (memberDiscussion || (entityUser.role != "user" && entityUser.role != "coadmin")))
&& handleKomentar()
}}
style={[Platform.OS == 'android' && Styles.mb12]}
>
<MaterialIcons name="send" size={25} style={(loadingSendKomentar || komentar == '' || regexOnlySpacesOrEnter.test(komentar) || data?.status === 2 || !data?.isActive || (!memberDiscussion && (entityUser.role == "user" || entityUser.role == "coadmin"))) ? { color: colors.dimmed } : { color: colors.tint }} />
<MaterialIcons name="send" size={25} style={(loadingSendKomentar || komentar == '' || regexOnlySpacesOrEnter.test(komentar) || data?.status === 2 || !data?.isActive || (!memberDiscussion && (entityUser.role == "user" || entityUser.role == "coadmin"))) ? { color: colors.dimmed } : { color: colors.tint }} />
</Pressable>
}
/>

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import ImageUser from "@/components/imageNew";
import { InputForm } from "@/components/inputForm";
@@ -17,14 +15,33 @@ import { setUpdateDiscussionGeneralDetail } from "@/lib/discussionGeneralDetail"
import { setMemberChoose } from "@/lib/memberChoose";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function CreateDiscussionGeneral() {
const { token, decryptToken } = useAuthSession()
@@ -43,84 +60,67 @@ export default function CreateDiscussionGeneral() {
const [fileForm, setFileForm] = useState<any[]>([])
const [isModalFile, setModalFile] = useState(false)
const [indexDelFile, setIndexDelFile] = useState<number>(0)
const [dataForm, setDataForm] = useState({
idGroup: "",
title: "",
desc: "",
});
const [error, setError] = useState({
group: false,
title: false,
desc: false,
});
const [dataForm, setDataForm] = useState({ idGroup: "", title: "", desc: "" });
const [error, setError] = useState({ group: false, title: false, desc: false });
function validationForm(cat: string, val: any, label?: string) {
if (cat == "group") {
if (cat === "group") {
setChooseGroup({ val, label: String(label) });
dispatch(setMemberChoose([]))
setDataForm({ ...dataForm, idGroup: val });
if (val == "" || val == "null") {
setError({ ...error, group: true });
} else {
setError({ ...error, group: false });
}
} else if (cat == "title") {
setError({ ...error, group: val === "" || val === "null" });
} else if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (
Object.values(error).some((v) => v == true) ||
Object.values(dataForm).some((v) => v == "")
) {
setDisableBtn(true);
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => { checkForm() }, [error, dataForm]);
useEffect(() => { dispatch(setMemberChoose([])) }, [])
function handleOpenMemberPicker() {
if (entityUser.role === "supadmin" || entityUser.role === "developer") {
if (chooseGroup.val !== "") {
setSelect(true);
setValSelect("member");
} else {
Toast.show({ type: 'small', text1: 'Pilih Lembaga Desa terlebih dahulu' })
}
} else {
setDisableBtn(false);
validationForm('group', userLogin.idGroup, userLogin.group);
setValChoose(userLogin.idGroup)
setSelect(true);
setValSelect("member");
}
}
useEffect(() => {
checkForm();
}, [error, dataForm]);
useEffect(() => {
dispatch(setMemberChoose([]))
}, [])
function handleBack() {
dispatch(setMemberChoose([]))
router.back()
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
if (fileForm.some(f => f.name === asset.name)) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number) {
setFileForm([...fileForm.filter((val, i) => i !== index)])
setFileForm(fileForm.filter((_, i) => i !== index))
setModalFile(false)
}
@@ -129,38 +129,22 @@ export default function CreateDiscussionGeneral() {
setLoading(true)
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{ ...dataForm, user: hasil, member: entitiesMember }
))
fd.append("data", JSON.stringify({ ...dataForm, user: hasil, member: entitiesMember }))
const response = await apiCreateDiscussionGeneral(fd)
// const response = await apiCreateDiscussionGeneral({
// data: { ...dataForm, user: hasil, member: entitiesMember },
// })
if (response.success) {
dispatch(setMemberChoose([]))
dispatch(setUpdateDiscussionGeneralDetail(!update))
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', })
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data' })
router.back()
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan data" })
} finally {
setLoading(false)
}
@@ -170,36 +154,18 @@ export default function CreateDiscussionGeneral() {
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => { handleBack() }}
// />
// ),
headerTitle: "Tambah Diskusi",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// category="create"
// disable={disableBtn || loading ? true : false}
// onPress={() => {
// entitiesMember.length == 0
// ? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota', })
// : handleCreate()
// }}
// />
// ),
header: () => (
<AppHeader
title="Tambah Diskusi"
showBack={true}
onPressLeft={() => router.back()}
onPressLeft={() => { dispatch(setMemberChoose([])); router.back() }}
right={
<ButtonSaveHeader
category="create"
disable={disableBtn || loading ? true : false}
disable={disableBtn || entitiesMember.length === 0 || loading}
onPress={() => {
entitiesMember.length == 0
? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota', })
entitiesMember.length === 0
? Toast.show({ type: 'small', text1: 'Anda belum memilih anggota' })
: handleCreate()
}}
/>
@@ -211,25 +177,20 @@ export default function CreateDiscussionGeneral() {
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15, Styles.mb100]}>
{
(entityUser.role == "supadmin" ||
entityUser.role == "developer") && (
<SelectForm
label="Lembaga Desa"
placeholder="Pilih Lembaga Desa"
value={chooseGroup.label}
required
bg={colors.card}
onPress={() => {
setValChoose(chooseGroup.val);
setValSelect("group");
setSelect(true);
}}
error={error.group}
errorText="Lembaga Desa tidak boleh kosong"
/>
)
}
{(entityUser.role === "supadmin" || entityUser.role === "developer") && (
<SelectForm
label="Lembaga Desa"
placeholder="Pilih Lembaga Desa"
value={chooseGroup.label}
required
bg={colors.card}
onPress={() => { setValChoose(chooseGroup.val); setValSelect("group"); setSelect(true) }}
error={error.group}
errorText="Lembaga Desa tidak boleh kosong"
/>
)}
<InputForm
label="Judul"
type="default"
@@ -238,8 +199,9 @@ export default function CreateDiscussionGeneral() {
error={error.title}
bg={colors.card}
errorText="Judul tidak boleh kosong"
onChange={(val) => { validationForm("title", val) }}
onChange={(val) => validationForm("title", val)}
/>
<InputForm
label="Diskusi"
type="default"
@@ -248,93 +210,107 @@ export default function CreateDiscussionGeneral() {
error={error.desc}
bg={colors.card}
errorText="Diskusi tidak boleh kosong"
onChange={(val) => { validationForm("desc", val) }}
onChange={(val) => validationForm("desc", val)}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
fileForm.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: fileForm.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
fileForm.map((item, index) => (
<BorderBottomItem
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{fileForm.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{fileForm.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{fileForm.length} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{fileForm.length > 0 && (
<View style={Styles.fileGrid}>
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
bgColor="transparent"
titleWeight="normal"
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
/>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
<ButtonSelect
value="Pilih Anggota"
onPress={() => {
if (entityUser.role == "supadmin" || entityUser.role == "developer") {
if (chooseGroup.val != "") {
setSelect(true);
setValSelect("member");
} else {
Toast.show({ type: 'small', text1: 'Pilih Lembaga Desa terlebih dahulu', })
}
} else {
validationForm('group', userLogin.idGroup, userLogin.group);
setValChoose(userLogin.idGroup)
setSelect(true);
setValSelect("member");
}
)}
</View>
}}
/>
{
entitiesMember.length > 0 &&
<View>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Anggota</Text>
<Text style={[Styles.textDefault]}>{entitiesMember.length} Anggota</Text>
{/* Anggota */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={handleOpenMemberPicker}
style={[Styles.sectionActionRow, { marginBottom: entitiesMember.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialIcons name="people" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Anggota</Text>
{entitiesMember.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada anggota dipilih</Text>
)}
</View>
{entitiesMember.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{entitiesMember.length} anggota</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{entitiesMember.length > 0 && (
<View style={{ gap: 6 }}>
{entitiesMember.map((item: any, index: number) => (
<View key={index} style={[Styles.listItemCard, { borderColor: colors.icon + '18' }]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{item.name}
</Text>
</View>
))}
</View>
)}
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
entitiesMember.map((item: { img: any; name: any; }, index: any) => {
return (
<BorderBottomItem
key={index}
borderType={entitiesMember.length - 1 == index ? "none" : "bottom"}
icon={
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
}
title={item.name}
bgColor="transparent"
/>
)
})
}
</View>
</View>
}
</View>
</ScrollView>
<ModalSelect
category={valSelect}
close={setSelect}
onSelect={(value) => {
validationForm(valSelect, value.val, value.label);
}}
title={valSelect == "group" ? "Lembaga Desa" : "Pilih Anggota"}
onSelect={(value) => validationForm(valSelect, value.val, value.label)}
title={valSelect === "group" ? "Lembaga Desa" : "Pilih Anggota"}
open={isSelect}
idParent={valSelect == "member" ? chooseGroup.val : ""}
idParent={valSelect === "member" ? chooseGroup.val : ""}
valChoose={valChoose}
/>
@@ -343,7 +319,7 @@ export default function CreateDiscussionGeneral() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile) }}
onPress={() => deleteFile(indexDelFile)}
/>
</View>
</DrawerBottom>

View File

@@ -1,12 +1,10 @@
import AppHeader from "@/components/AppHeader";
import Text from "@/components/Text";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
import MenuItemRow from "@/components/menuItemRow";
import Text from "@/components/Text";
import Styles from "@/constants/Styles";
import { apiEditDiscussionGeneral, apiGetDiscussionGeneralOne } from "@/lib/api";
import { setUpdateDiscussionGeneralDetail } from "@/lib/discussionGeneralDetail";
@@ -16,10 +14,30 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function EditDiscussionGeneral() {
const { token, decryptToken } = useAuthSession();
const { colors } = useTheme();
@@ -32,136 +50,91 @@ export default function EditDiscussionGeneral() {
const [indexDelFile, setIndexDelFile] = useState<{ id: string | number; cat: "newFile" | "oldFile" }>({ id: "", cat: "newFile" })
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string; delete?: boolean }[]>([])
const update = useSelector((state: any) => state.discussionGeneralDetailUpdate)
const [dataForm, setDataForm] = useState({
title: "",
desc: "",
});
const [error, setError] = useState({
title: false,
desc: false,
})
const [dataForm, setDataForm] = useState({ title: "", desc: "" });
const [error, setError] = useState({ title: false, desc: false })
const visibleOldFiles = dataFile.filter(v => !v.delete)
const totalFiles = fileForm.length + visibleOldFiles.length
async function handleLoad() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetDiscussionGeneralOne({
id: id,
user: hasil,
cat: "detail",
});
const responseFile = await apiGetDiscussionGeneralOne({
id: id,
user: hasil,
cat: "file",
});
if (response.success) {
setDataForm(response.data);
}
if (responseFile.success) {
setDataFile(responseFile.data);
}
const response = await apiGetDiscussionGeneralOne({ id, user: hasil, cat: "detail" });
const responseFile = await apiGetDiscussionGeneralOne({ id, user: hasil, cat: "file" });
if (response.success) setDataForm(response.data);
if (responseFile.success) setDataFile(responseFile.data);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
handleLoad();
}, []);
useEffect(() => { handleLoad() }, []);
function validationForm(cat: string, val: any) {
if (cat == "title") {
if (cat === "title") {
setDataForm({ ...dataForm, title: val });
if (val == "" || val == "null") {
setError({ ...error, title: true });
} else {
setError({ ...error, title: false });
}
} else if (cat == "desc") {
setError({ ...error, title: val === "" || val === "null" });
} else if (cat === "desc") {
setDataForm({ ...dataForm, desc: val });
if (val == "" || val == "null") {
setError({ ...error, desc: true });
} else {
setError({ ...error, desc: false });
}
setError({ ...error, desc: val === "" || val === "null" });
}
}
function checkForm() {
if (Object.values(error).some((v) => v == true) == true || Object.values(dataForm).some((v) => v == "") == true) {
setDisableBtn(true)
} else {
setDisableBtn(false)
}
const hasError = Object.values(error).some(v => v)
const hasEmpty = Object.values(dataForm).some(v => v === "")
setDisableBtn(hasError || hasEmpty);
}
useEffect(() => {
checkForm()
}, [error, dataForm])
useEffect(() => { checkForm() }, [error, dataForm])
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
const isDup = fileForm.some(f => f.name === asset.name) ||
visibleOldFiles.some(f => `${f.name}.${f.extension}` === asset.name)
if (isDup) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat == "newFile") {
setFileForm([...fileForm.filter((val, i) => i !== index)])
if (cat === "newFile") {
setFileForm(fileForm.filter((_, i) => i !== index))
} else {
setDataFile(prev =>
prev.map(item =>
item.id === index
? { ...item, delete: true }
: item
)
);
setDataFile(prev => prev.map(item => item.id === index ? { ...item, delete: true } : item))
}
setModalFile(false)
}
async function handleEdit() {
try {
setLoading(true)
const hasil = await decryptToken(String(token?.current));
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{
user: hasil, title: dataForm.title, desc: dataForm.desc, oldFile: dataFile
}
))
fd.append("data", JSON.stringify({ user: hasil, title: dataForm.title, desc: dataForm.desc, oldFile: dataFile }))
const response = await apiEditDiscussionGeneral(fd, id);
if (response.success) {
dispatch(setUpdateDiscussionGeneralDetail(!update))
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', })
Toast.show({ type: 'small', text1: 'Berhasil mengubah data' })
router.back();
} else {
Toast.show({ type: 'small', text1: 'Gagal mengubah data', })
Toast.show({ type: 'small', text1: 'Gagal mengubah data' })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengubah data" })
} finally {
setLoading(false)
}
@@ -171,22 +144,6 @@ export default function EditDiscussionGeneral() {
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Edit Diskusi",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={disableBtn || loading ? true : false}
// category="update"
// onPress={() => { handleEdit() }}
// />
// ),
header: () => (
<AppHeader
title="Edit Diskusi"
@@ -194,9 +151,9 @@ export default function EditDiscussionGeneral() {
onPressLeft={() => router.back()}
right={
<ButtonSaveHeader
disable={disableBtn || loading ? true : false}
disable={disableBtn || loading}
category="update"
onPress={() => { handleEdit() }}
onPress={() => handleEdit()}
/>
}
/>
@@ -205,7 +162,8 @@ export default function EditDiscussionGeneral() {
/>
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15]}>
<View style={Styles.p15}>
<InputForm
label="Judul"
type="default"
@@ -217,6 +175,7 @@ export default function EditDiscussionGeneral() {
errorText="Judul tidak boleh kosong"
onChange={(val) => validationForm("title", val)}
/>
<InputForm
label="Diskusi"
type="default"
@@ -229,45 +188,77 @@ export default function EditDiscussionGeneral() {
onChange={(val) => validationForm("desc", val)}
multiline
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
(fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0)
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length + dataFile.filter((val) => !val.delete).length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: totalFiles > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataFile.filter((val) => !val.delete).map((item, index) => (
<BorderBottomItem
key={index}
borderType={dataFile.filter((val) => !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + '.' + item.extension}
titleWeight="normal"
bgColor="transparent"
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{totalFiles === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{totalFiles > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{totalFiles} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{totalFiles > 0 && (
<View style={Styles.fileGrid}>
{visibleOldFiles.map((item, index) => {
const ext = item.extension.toLowerCase()
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`old-${index}`}
onPress={() => { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }}
/>
))
}
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
bgColor="transparent"
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`new-${index}`}
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
/>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
</View>
</ScrollView>
@@ -276,7 +267,7 @@ export default function EditDiscussionGeneral() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }}
onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)}
/>
</View>
</DrawerBottom>

View File

@@ -1,23 +1,23 @@
import BorderBottomItem from "@/components/borderBottomItem";
import GuideOverlay from "@/components/GuideOverlay";
import ButtonTab from "@/components/buttonTab";
import InputSearch from "@/components/inputSearch";
import LabelStatus from "@/components/labelStatus";
import SkeletonContent from "@/components/skeletonContent";
import Text from "@/components/Text";
import WrapTab from "@/components/wrapTab";
import { ColorsStatus } from "@/constants/ColorsStatus";
import Styles from "@/constants/Styles";
import { apiGetDiscussionGeneral } from "@/lib/api";
import { GUIDE_DISCUSSION } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather, Ionicons, MaterialIcons } from "@expo/vector-icons";
import { AntDesign, Feather } from "@expo/vector-icons";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { RefreshControl, View, VirtualizedList } from "react-native";
import { FlatList, Pressable, RefreshControl, View } from "react-native";
import { useSelector } from "react-redux";
type Props = {
id: string
title: string
@@ -37,8 +37,8 @@ export default function Discussion() {
const queryClient = useQueryClient()
const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
const [refreshing, setRefreshing] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('discussion')
// TanStack Query for Discussions with Infinite Scroll
const {
data,
fetchNextPage,
@@ -50,12 +50,12 @@ export default function Discussion() {
queryKey: ['discussions', { status, search, group }],
queryFn: async ({ pageParam = 1 }) => {
const hasil = await decryptToken(String(token?.current))
const response = await apiGetDiscussionGeneral({
user: hasil,
active: status,
search: search,
group: String(group),
page: pageParam
const response = await apiGetDiscussionGeneral({
user: hasil,
active: status,
search: search,
group: String(group),
page: pageParam
})
return response;
},
@@ -67,17 +67,14 @@ export default function Discussion() {
staleTime: 0,
})
// Flatten pages into a single data array
const flatData = useMemo(() => {
return data?.pages.flatMap(page => page.data) || [];
}, [data])
// Get nameGroup from the first available page
const nameGroup = useMemo(() => {
return data?.pages[0]?.filter?.name || "";
}, [data])
// Refetch when manual update state changes
useEffect(() => {
refetch()
}, [update, refetch])
@@ -88,113 +85,141 @@ export default function Discussion() {
setRefreshing(false)
};
const loadMoreData = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
};
const isOpen = (item: Props) => item.status === 1
const arrSkeleton = [0, 1, 2, 3, 4]
const getItem = (_data: unknown, index: number): Props => ({
id: flatData[index]?.id,
title: flatData[index]?.title,
desc: flatData[index]?.desc,
status: flatData[index]?.status,
total_komentar: flatData[index]?.total_komentar,
createdAt: flatData[index]?.createdAt,
})
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '20' },
cardPressed: { backgroundColor: colors.icon + '10' },
iconCircle: { backgroundColor: colors.icon + '20' },
title: { color: colors.text },
dimmed: { color: colors.dimmed },
statusOpen: { borderColor: '#10B981' as const },
statusClosed: { borderColor: colors.dimmed + '80' },
statusTextOpen: { color: '#10B981' as const },
statusTextClosed: { color: colors.dimmed },
}
return (
<View style={[Styles.p15, Styles.flex1, { backgroundColor: colors.background }]}>
<View>
{
entityUser.role != "user" && entityUser.role != "coadmin" &&
<View style={[Styles.flex1, themed.background]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_DISCUSSION} onDismiss={dismissGuide} />
{/* Header controls */}
<View style={[Styles.ph15, Styles.discussionHeaderPadding]}>
{entityUser.role != "user" && entityUser.role != "coadmin" && (
<WrapTab>
<ButtonTab
active={status == "false" ? "false" : "true"}
value="true"
onPress={() => { setStatus("true") }}
onPress={() => setStatus("true")}
label="Aktif"
icon={<Feather name="check-circle" color={status == "false" ? colors.dimmed : 'white'} size={20} />}
n={2} />
n={2}
/>
<ButtonTab
active={status == "false" ? "false" : "true"}
value="false"
onPress={() => { setStatus("false") }}
onPress={() => setStatus("false")}
label="Arsip"
icon={<AntDesign name="closecircleo" color={status == "true" ? colors.dimmed : 'white'} size={20} />}
n={2} />
n={2}
/>
</WrapTab>
}
)}
<InputSearch onChange={setSearch} />
{
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<View style={[Styles.mv05, Styles.rowOnly]}>
{(entityUser.role == "supadmin" || entityUser.role == "developer") && (
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>
}
)}
</View>
<View style={[Styles.flex2, Styles.mt05]}>
{
isLoading ?
arrSkeleton.map((item: any, i: number) => {
return (
<SkeletonContent key={i} />
)
})
:
flatData.length > 0
?
<VirtualizedList
data={flatData}
getItemCount={() => flatData.length}
getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => {
return (
<BorderBottomItem
bgColor="transparent"
key={index}
onPress={() => { router.push(`/discussion/${item.id}`) }}
borderType="bottom"
icon={
<MaterialIcons name="chat" size={25} color={colors.text} />
}
title={item.title}
subtitle={
status != "false" && <LabelStatus category={item.status === 1 ? "success" : "error"} text={item.status === 1 ? "BUKA" : "TUTUP"} size="small" />
}
rightTopInfo={item.createdAt}
desc={item.desc?.replace(/<[^>]*>?/gm, ' ').replace(/\r?\n|\r/g, ' ')}
leftBottomInfo={
<View style={[Styles.rowItemsCenter]}>
<Ionicons name="chatbox-ellipses-outline" size={18} color={colors.dimmed} style={Styles.mr05} />
<Text style={[Styles.textInformation, { color: colors.dimmed }, Styles.mb05]}>Diskusikan</Text>
</View>
}
rightBottomInfo={`${item.total_komentar} Komentar`}
/>
)
}}
keyExtractor={(item, index) => String(index)}
onEndReached={loadMoreData}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
{/* List */}
<View style={[Styles.flex1, Styles.ph15, Styles.discussionListPadding]}>
{isLoading ? (
[0, 1, 2, 3, 4].map((_, i) => <SkeletonContent key={i} />)
) : flatData.length === 0 ? (
<View style={[Styles.contentItemCenter, Styles.mt30]}>
<Feather name="message-circle" size={42} color={colors.icon + '40'} />
<Text style={[Styles.mt10, Styles.discussionEmptyText, themed.dimmed]}>
Tidak ada diskusi
</Text>
</View>
) : (
<FlatList
data={flatData}
keyExtractor={(_, i) => String(i)}
showsVerticalScrollIndicator={false}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage()
}}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
:
<Text style={[Styles.textDefault, Styles.textCenter, { color: colors.dimmed }]}>Tidak ada data</Text>
}
}
ItemSeparatorComponent={() => <View style={Styles.discussionSeparator} />}
renderItem={({ item }: { item: Props }) => (
<Pressable
onPress={() => router.push(`/discussion/${item.id}`)}
style={({ pressed }) => [
Styles.discussionCard,
themed.card,
pressed && themed.cardPressed,
]}
>
{/* Top row: icon + title + status badge */}
<View style={[Styles.rowItemsCenter, Styles.mb08]}>
{/* Discussion icon */}
<View style={[Styles.discussionIconCircle, themed.iconCircle]}>
<Feather name="message-circle" size={20} color={colors.icon} />
</View>
{/* Title + status badge */}
<View style={[Styles.flex1, Styles.discussionTitleCol]}>
<Text style={[Styles.textDefaultSemiBold, themed.title]} numberOfLines={1}>
{item.title}
</Text>
{status !== "false" && (
<View style={[Styles.discussionStatusPill, isOpen(item) ? themed.statusOpen : themed.statusClosed]}>
<Text style={[Styles.discussionStatusText, isOpen(item) ? themed.statusTextOpen : themed.statusTextClosed]}>
{isOpen(item) ? 'Buka' : 'Tutup'}
</Text>
</View>
)}
</View>
</View>
{/* Description */}
{item.desc ? (
<Text
style={[Styles.textMediumNormal, Styles.discussionCardIndent, Styles.discussionDescMargin, themed.title]}
numberOfLines={2}
>
{item.desc.replace(/<[^>]*>?/gm, ' ').replace(/\r?\n|\r/g, ' ')}
</Text>
) : null}
{/* Bottom row: comment count + date */}
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.discussionCardIndent]}>
<View style={Styles.rowItemsCenter}>
<Feather name="message-square" size={14} color={colors.dimmed} />
<Text style={[Styles.discussionCommentText, themed.dimmed]}>
{item.total_komentar} Komentar
</Text>
</View>
<Text style={[Styles.discussionDateText, themed.dimmed]}>
{item.createdAt}
</Text>
</View>
</Pressable>
)}
/>
)}
</View>
</View>
);
}
}

View File

@@ -1,21 +1,18 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import DrawerBottom from "@/components/drawerBottom";
import ImageUser from "@/components/imageNew";
import MenuItemRow from "@/components/menuItemRow";
import ModalConfirmation from "@/components/ModalConfirmation";
import SkeletonTwoItem from "@/components/skeletonTwoItem";
import Text from '@/components/Text';
import { ColorsStatus } from "@/constants/ColorsStatus";
import { ConstEnv } from "@/constants/ConstEnv";
import Styles from "@/constants/Styles";
import { apiDeleteMemberDiscussionGeneral, apiGetDiscussionGeneralOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather, MaterialCommunityIcons } from "@expo/vector-icons";
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useSelector } from "react-redux";
@@ -25,6 +22,8 @@ type Props = {
img: string
}
const SKELETON_COUNT = 5
export default function MemberDiscussionDetail() {
const { token, decryptToken } = useAuthSession()
const { colors } = useTheme();
@@ -35,13 +34,12 @@ export default function MemberDiscussionDetail() {
const [chooseUser, setChooseUser] = useState({ idUser: '', name: '', img: '' })
const update = useSelector((state: any) => state.discussionGeneralDetailUpdate)
const [loading, setLoading] = useState(true)
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const canManage = entityUser.role !== "user" && entityUser.role !== "coadmin"
async function handleLoad(loading: boolean) {
async function handleLoad(showLoadingIndicator: boolean) {
try {
setLoading(loading)
setLoading(showLoadingIndicator)
const hasil = await decryptToken(String(token?.current))
const response = await apiGetDiscussionGeneralOne({ id: id, user: hasil, cat: 'anggota' })
setData(response.data)
@@ -52,26 +50,18 @@ export default function MemberDiscussionDetail() {
}
}
useEffect(() => {
handleLoad(false)
}, [update]);
useEffect(() => {
handleLoad(true)
}, []);
useEffect(() => { handleLoad(false) }, [update]);
useEffect(() => { handleLoad(true) }, []);
async function handleDeleteUser() {
try {
const hasil = await decryptToken(String(token?.current))
await apiDeleteMemberDiscussionGeneral({ user: hasil, idUser: chooseUser.idUser }, id)
Toast.show({ type: 'small', text1: 'Berhasil mengeluarkan anggota dari diskusi', })
Toast.show({ type: 'small', text1: 'Berhasil mengeluarkan anggota dari diskusi' })
handleLoad(false)
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengeluarkan anggota"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengeluarkan anggota" })
} finally {
setModal(false)
}
@@ -81,9 +71,6 @@ export default function MemberDiscussionDetail() {
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Anggota Diskusi',
headerTitleAlign: 'center',
header: () => (
<AppHeader
title="Anggota Diskusi"
@@ -93,49 +80,82 @@ export default function MemberDiscussionDetail() {
)
}}
/>
<ScrollView style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15]}>
<Text style={[Styles.textDefault, Styles.mv05]}>{data.length} Anggota</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
entityUser.role != "user" && entityUser.role != "coadmin" &&
<BorderBottomItem
onPress={() => { router.push(`/discussion/add-member/${id}`) }}
borderType="none"
icon={
<View style={[Styles.iconContent, ColorsStatus.gray]}>
<Feather name="user-plus" size={25} color={'#384288'} />
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15, Styles.mb100]}>
{/* Tombol tambah anggota */}
{canManage && (
<View style={[Styles.wrapPaper, Styles.sectionCard, Styles.mb15,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={() => router.push(`/discussion/add-member/${id}`)}
style={Styles.sectionActionRow}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '18' }]}>
<MaterialCommunityIcons name="account-plus-outline" size={18} color={colors.icon} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Tambah Anggota</Text>
</View>
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
</View>
)}
{/* Full list */}
<View style={[Styles.wrapPaper, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18', padding: 0, overflow: 'hidden' }]}>
<View style={[Styles.sectionActionRow, { padding: 16, borderBottomWidth: 1, borderBottomColor: colors.icon + '14' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialIcons name="people" size={18} color={colors.dimmed} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Anggota</Text>
{!loading && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>{data.length} anggota</Text>
)}
</View>
{loading
? Array.from({ length: SKELETON_COUNT }).map((_, i) => (
<View
key={i}
style={[Styles.rowItemsCenter, Styles.ph15,
{ paddingVertical: 14, gap: 14, borderBottomWidth: i < SKELETON_COUNT - 1 ? 1 : 0, borderBottomColor: colors.icon + '14' }]}
>
<View style={[Styles.userProfileExtraSmall, { backgroundColor: colors.icon + '20', borderRadius: 100 }]} />
<View style={{ height: 13, borderRadius: 6, flex: 1, backgroundColor: colors.icon + '20', maxWidth: 140 + (i % 3) * 30 }} />
</View>
))
: data.length === 0
? (
<View style={[Styles.contentItemCenter, { paddingVertical: 40 }]}>
<MaterialIcons name="people-outline" size={34} color={colors.icon + '50'} />
<Text style={[Styles.textMediumNormal, Styles.mt10, { color: colors.dimmed }]}>Belum ada anggota</Text>
</View>
}
title="Tambah Anggota"
/>
}
{
loading ?
arrSkeleton.map((item, index) => {
return (
<SkeletonTwoItem key={index} />
)
})
:
data.map((item, index) => {
return (
<BorderBottomItem
key={index}
borderType="bottom"
icon={
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="sm" />
}
title={item.name}
onPress={() => {
setChooseUser(item)
setModal(true)
}}
/>
)
})
)
: data.map((item, index) => (
<Pressable
key={index}
onPress={() => { setChooseUser(item); setModal(true) }}
style={({ pressed }) => [
Styles.rowItemsCenter, Styles.ph15,
{
paddingVertical: 13, gap: 14,
borderBottomWidth: index < data.length - 1 ? 1 : 0,
borderBottomColor: colors.icon + '14',
backgroundColor: pressed ? colors.icon + '0E' : 'transparent',
},
]}
>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{item.name}
</Text>
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.icon + '60'} />
</Pressable>
))
}
</View>
</View>
</ScrollView>
@@ -149,20 +169,16 @@ export default function MemberDiscussionDetail() {
router.push(`/member/${chooseUser.idUser}`)
}}
/>
{
entityUser.role != "user" && entityUser.role != "coadmin" &&
{canManage && (
<MenuItemRow
icon={<MaterialCommunityIcons name="account-remove" color={colors.text} size={25} />}
title="Keluarkan"
onPress={() => {
setModal(false)
setTimeout(() => {
setShowDeleteModal(true)
}, 600)
setTimeout(() => setShowDeleteModal(true), 600)
}}
/>
}
)}
</View>
</DrawerBottom>
@@ -170,10 +186,7 @@ export default function MemberDiscussionDetail() {
visible={showDeleteModal}
title="Konfirmasi"
message="Apakah anda yakin ingin mengeluarkan anggota?"
onConfirm={() => {
setShowDeleteModal(false)
handleDeleteUser()
}}
onConfirm={() => { setShowDeleteModal(false); handleDeleteUser() }}
onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus"
cancelText="Batal"

View File

@@ -1,12 +1,9 @@
import ModalConfirmation from "@/components/ModalConfirmation"
import AppHeader from "@/components/AppHeader"
import BorderBottomItem from "@/components/borderBottomItem"
import ButtonBackHeader from "@/components/buttonBackHeader"
import HeaderRightCalendarDetail from "@/components/calendar/headerCalendarDetail"
import DrawerBottom from "@/components/drawerBottom"
import HeaderRightCalendarDetail from "@/components/calendar/headerCalendarDetail"
import ImageUser from "@/components/imageNew"
import MenuItemRow from "@/components/menuItemRow"
import Skeleton from "@/components/skeleton"
import ModalConfirmation from "@/components/ModalConfirmation"
import Text from "@/components/Text"
import { ConstEnv } from "@/constants/ConstEnv"
import Styles from "@/constants/Styles"
@@ -14,7 +11,7 @@ import { apiDeleteCalendarMember, apiGetCalendarOne, apiGetDivisionOneFeature }
import { setUpdateCalendar } from "@/lib/calendarUpdate"
import { useAuthSession } from "@/providers/AuthProvider"
import { useTheme } from "@/providers/ThemeProvider"
import { MaterialCommunityIcons } from "@expo/vector-icons"
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"
import Clipboard from "@react-native-clipboard/clipboard"
import { router, Stack, useLocalSearchParams } from "expo-router"
import { useEffect, useState } from "react"
@@ -156,135 +153,142 @@ export default function DetailEventCalendar() {
setRefreshing(false)
};
const canManage = !((entityUser.role === "user" || entityUser.role === "coadmin") && !isMemberDivision)
const repeatLabel: Record<string, string> = {
once: 'Acara 1 Kali',
daily: 'Setiap Hari',
weekly: 'Mingguan',
monthly: 'Bulanan',
yearly: 'Tahunan',
}
function InfoRow({ icon, label, value, onCopy }: { icon: string, label: string, value?: string, onCopy?: () => void }) {
return (
<View style={[Styles.sectionActionRow, { paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: colors.icon + '14' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialCommunityIcons name={icon as any} size={18} color={colors.dimmed} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed, marginBottom: 2 }]}>{label}</Text>
{loading
? <View style={{ height: 13, borderRadius: 6, backgroundColor: colors.icon + '20', width: '70%' }} />
: <Text style={[Styles.textDefault, { color: colors.text }]}>{value || '-'}</Text>
}
</View>
{onCopy && !loading && value && (
<Pressable onPress={onCopy} style={{ padding: 4 }}>
<MaterialCommunityIcons name="content-copy" size={16} color={colors.dimmed} />
</Pressable>
)}
</View>
)
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Detail Acara',
headerTitleAlign: 'center',
// headerRight: () => (entityUser.role == "user" || entityUser.role == "coadmin") && !isMemberDivision ? <></> : <HeaderRightCalendarDetail id={String(data?.idCalendar)} idReminder={String(detail)} />
header: () => (
<AppHeader
title="Detail Acara"
showBack={true}
onPressLeft={() => router.back()}
right={
(entityUser.role == "user" || entityUser.role == "coadmin") && !isMemberDivision ? <></> : <HeaderRightCalendarDetail id={String(data?.idCalendar)} idReminder={String(detail)} />
(entityUser.role === "user" || entityUser.role === "coadmin") && !isMemberDivision
? <></> : <HeaderRightCalendarDetail id={String(data?.idCalendar)} idReminder={String(detail)} />
}
/>
)
}}
/>
<ScrollView
style={[Styles.h100]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
showsVerticalScrollIndicator={false}
style={Styles.h100}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.icon} />}
>
<View style={[Styles.p15]}>
<View style={[Styles.wrapPaper, Styles.mb15, { backgroundColor: colors.card, borderColor: colors.background }]}>
<View style={[Styles.rowItemsCenter, { alignItems: 'flex-start' }]}>
<MaterialCommunityIcons name="calendar-text" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
: <Text style={[Styles.textDefault, Styles.w90]}>{data?.title}</Text>
}
<View style={[Styles.p15, Styles.mb100]}>
{/* Info acara */}
<View style={[Styles.wrapPaper, Styles.sectionCard, Styles.noShadow, Styles.mb15,
{ backgroundColor: colors.card, borderColor: colors.icon + '18', padding: 0, overflow: 'hidden' }]}>
<View style={{ padding: 16, borderBottomWidth: 1, borderBottomColor: colors.icon + '14' }}>
<View style={Styles.sectionActionRow}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialCommunityIcons name="calendar-text" size={18} color={colors.dimmed} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Detail Acara</Text>
</View>
</View>
<View style={[Styles.rowItemsCenter, Styles.mt10]}>
<MaterialCommunityIcons name="calendar-month-outline" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
:
<Text style={[Styles.textDefault]}>{data?.dateStart}</Text>
}
<View style={{ paddingHorizontal: 16 }}>
<InfoRow icon="format-title" label="Judul" value={data?.title} />
<InfoRow icon="calendar-month-outline" label="Tanggal" value={data?.dateStart} />
<InfoRow icon="clock-outline" label="Waktu" value={data ? `${data.timeStart} ${data.timeEnd}` : undefined} />
<InfoRow icon="repeat" label="Pengulangan" value={data?.repeatEventTyper ? repeatLabel[data.repeatEventTyper] : undefined} />
<InfoRow icon="link-variant" label="Link Meet" value={data?.linkMeet} onCopy={data?.linkMeet ? () => handleCopy(data.linkMeet) : undefined} />
<View style={[Styles.sectionActionRow, { paddingVertical: 10, alignItems: 'flex-start' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialCommunityIcons name="card-text-outline" size={18} color={colors.dimmed} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed, marginBottom: 2 }]}>Deskripsi</Text>
{loading
? <View style={{ height: 13, borderRadius: 6, backgroundColor: colors.icon + '20', width: '80%' }} />
: <Text style={[Styles.textDefault, { color: colors.text, lineHeight: 22 }]}>{data?.desc || '-'}</Text>
}
</View>
</View>
</View>
<View style={[Styles.rowItemsCenter, Styles.mt10]}>
<MaterialCommunityIcons name="clock-outline" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
:
<Text style={[Styles.textDefault]}>{data?.timeStart} | {data?.timeEnd}</Text>
}
</View>
{/* Daftar anggota */}
<View style={[Styles.wrapPaper, Styles.sectionCard, Styles.noShadow,
{ backgroundColor: colors.card, borderColor: colors.icon + '18', padding: 0, overflow: 'hidden' }]}>
<View style={[Styles.sectionActionRow, { padding: 16, borderBottomWidth: 1, borderBottomColor: colors.icon + '14' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialIcons name="people" size={18} color={colors.dimmed} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Anggota</Text>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>{member.length} anggota</Text>
</View>
<View style={[Styles.rowItemsCenter, Styles.mt10]}>
<MaterialCommunityIcons name="repeat" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
:
<Text style={[Styles.textDefault]}>
{member.length === 0
? (
<View style={[Styles.contentItemCenter, { paddingVertical: 40 }]}>
<MaterialIcons name="people-outline" size={34} color={colors.icon + '50'} />
<Text style={[Styles.textMediumNormal, Styles.mt10, { color: colors.dimmed }]}>Belum ada anggota</Text>
</View>
)
: member.map((item, index) => (
<Pressable
key={index}
onPress={() => {
if (!canManage) return
setMemberChoose({ id: item.idUser, name: item.name })
setModalMember(true)
}}
style={({ pressed }) => [
Styles.rowItemsCenter, Styles.ph15,
{
data?.repeatEventTyper.toString() === 'once' ? 'Acara 1 Kali' :
data?.repeatEventTyper.toString() === 'daily' ? 'Setiap Hari' :
data?.repeatEventTyper.toString() === 'weekly' ? 'Mingguan' :
data?.repeatEventTyper.toString() === 'monthly' ? 'Bulanan' :
data?.repeatEventTyper.toString() === 'yearly' ? 'Tahunan' :
''
}
</Text>
}
</View>
<View style={[Styles.rowItemsCenter, Styles.mt10]}>
<MaterialCommunityIcons name="link-variant" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
:
data?.linkMeet ?
<Pressable onPress={() => { handleCopy(data.linkMeet) }}>
<Text style={[Styles.textDefault]}>{data.linkMeet}</Text>
</Pressable>
: <Text style={[Styles.textDefault]}>-</Text>
}
</View>
<View style={[Styles.rowItemsCenter, Styles.mt10, { alignItems: 'flex-start' }]}>
<MaterialCommunityIcons name="card-text-outline" size={30} color={colors.text} style={Styles.mr10} />
{
loading ?
<Skeleton width={80} height={10} borderRadius={10} widthType="percent" />
:
<Text style={[Styles.textDefault, Styles.w90]}>{data?.desc}</Text>
}
</View>
paddingVertical: 12, gap: 14,
borderBottomWidth: index < member.length - 1 ? 1 : 0,
borderBottomColor: colors.icon + '14',
backgroundColor: pressed && canManage ? colors.icon + '0E' : 'transparent',
},
]}
>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<View style={Styles.flex1}>
<Text style={[Styles.textDefault, { color: colors.text }]} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]} numberOfLines={1}>{item.email}</Text>
</View>
{canManage && <MaterialCommunityIcons name="chevron-right" size={18} color={colors.icon + '60'} />}
</Pressable>
))
}
</View>
<View style={[Styles.mb15]}>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Anggota</Text>
<Text style={[Styles.textDefault]}>Total {member.length} Anggota</Text>
</View>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
member.map((item, index) => (
<BorderBottomItem
key={index}
borderType="bottom"
icon={<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} />}
title={item.name}
subtitle={item.email}
onPress={() => {
if ((entityUser.role == "user" || entityUser.role == "coadmin") && !isMemberDivision) {
null
} else {
setMemberChoose({ id: item.idUser, name: item.name })
setModalMember(true)
}
}}
/>
))
}
</View>
</View>
</View>
</ScrollView>

View File

@@ -1,4 +1,5 @@
import AppHeader from "@/components/AppHeader";
import GuideOverlay from "@/components/GuideOverlay";
import HeaderRightCalendarList from "@/components/calendar/headerCalendarList";
import ItemDateCalendar from "@/components/calendar/itemDateCalendar";
import EventItem from "@/components/eventItem";
@@ -6,6 +7,8 @@ import Skeleton from "@/components/skeleton";
import Text from "@/components/Text";
import Styles from "@/constants/Styles";
import { apiGetCalendarByDateDivision, apiGetIndicatorCalendar } from "@/lib/api";
import { GUIDE_DIVISION_CALENDAR } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather } from "@expo/vector-icons";
@@ -46,6 +49,7 @@ export default function CalendarDivision() {
const [loading, setLoading] = useState(true)
const [loadingBtn, setLoadingBtn] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-calendar')
async function handleLoad(loading: boolean) {
@@ -150,6 +154,7 @@ export default function CalendarDivision() {
)
}}
/>
<GuideOverlay visible={guideVisible} steps={GUIDE_DIVISION_CALENDAR} onDismiss={dismissGuide} />
<ScrollView
refreshControl={
<RefreshControl

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import { InputForm } from "@/components/inputForm";
import LoadingCenter from "@/components/loadingCenter";
@@ -16,10 +14,30 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function DiscussionDivisionEdit() {
const { colors } = useTheme();
const { id, detail } = useLocalSearchParams<{ id: string; detail: string }>();
@@ -33,30 +51,49 @@ export default function DiscussionDivisionEdit() {
const [indexDelFile, setIndexDelFile] = useState<{ id: string | number; cat: "newFile" | "oldFile" }>({ id: "", cat: "newFile" })
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string; delete?: boolean }[]>([])
const visibleOldFiles = dataFile.filter(v => !v.delete)
const totalFiles = fileForm.length + visibleOldFiles.length
async function handleLoad() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetDiscussionOne({
id: detail,
user: hasil,
cat: "data",
});
const response2 = await apiGetDiscussionOne({
id: detail,
user: hasil,
cat: "file",
});
setDataFile(response2.data);
const response = await apiGetDiscussionOne({ id: detail, user: hasil, cat: "data" });
const response2 = await apiGetDiscussionOne({ id: detail, user: hasil, cat: "file" });
setData(response.data.desc);
setDataFile(response2.data);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
handleLoad();
}, []);
useEffect(() => { handleLoad() }, []);
const pickDocumentAsync = async () => {
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
const isDup = fileForm.some(f => f.name === asset.name) ||
visibleOldFiles.some(f => `${f.name}.${f.extension}` === asset.name)
if (isDup) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat === "newFile") {
setFileForm(fileForm.filter((_, i) => i !== index))
} else {
setDataFile(prev => prev.map(item => item.id === index ? { ...item, delete: true } : item))
}
setModalFile(false)
}
async function handleUpdate() {
try {
@@ -64,94 +101,29 @@ export default function DiscussionDivisionEdit() {
const hasil = await decryptToken(String(token?.current));
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{
user: hasil, desc: data, oldFile: dataFile
}
))
fd.append("data", JSON.stringify({ user: hasil, desc: data, oldFile: dataFile }))
const response = await apiEditDiscussion(fd, detail);
// const response = await apiEditDiscussion({
// data: { user: hasil, desc: data },
// id: detail,
// });
if (response.success) {
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', })
Toast.show({ type: 'small', text1: 'Berhasil mengubah data' })
dispatch(setUpdateDiscussion({ ...update, data: !update.data }));
router.back();
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengubah data" })
} finally {
setLoading(false)
}
}
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
}
}
}
};
function deleteFile(index: number | string, cat: "newFile" | "oldFile" | null) {
if (cat == "newFile") {
setFileForm([...fileForm.filter((val, i) => i !== index)])
} else {
setDataFile(prev =>
prev.map(item =>
item.id === index
? { ...item, delete: true }
: item
)
);
}
setModalFile(false)
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Edit Diskusi",
headerTitleAlign: "center",
// headerRight: () => (
// <ButtonSaveHeader
// disable={data == "" || loading}
// category="update"
// onPress={() => {
// handleUpdate();
// }}
// />
// ),
header: () => (
<AppHeader
title="Edit Diskusi"
@@ -161,9 +133,7 @@ export default function DiscussionDivisionEdit() {
<ButtonSaveHeader
disable={data == "" || loading}
category="update"
onPress={() => {
handleUpdate();
}}
onPress={() => handleUpdate()}
/>
}
/>
@@ -171,8 +141,8 @@ export default function DiscussionDivisionEdit() {
}}
/>
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100]}>
<View style={[Styles.p15]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={Styles.p15}>
<InputForm
label="Diskusi"
type="default"
@@ -184,57 +154,87 @@ export default function DiscussionDivisionEdit() {
bg={colors.card}
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
(fileForm.length > 0 || dataFile.filter((val) => !val.delete).length > 0)
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length + dataFile.filter((val) => !val.delete).length} file</Text>
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: totalFiles > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
dataFile.filter((val) => !val.delete).map((item, index) => (
<BorderBottomItem
key={index}
borderType={dataFile.filter((val) => !val.delete).length - 1 == index && fileForm.length == 0 ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + '.' + item.extension}
titleWeight="normal"
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{totalFiles === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{totalFiles > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{totalFiles} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{totalFiles > 0 && (
<View style={Styles.fileGrid}>
{visibleOldFiles.map((item, index) => {
const ext = item.extension.toLowerCase()
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`old-${index}`}
onPress={() => { setIndexDelFile({ id: item.id, cat: "oldFile" }); setModalFile(true) }}
/>
))
}
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={`new-${index}`}
onPress={() => { setIndexDelFile({ id: index, cat: "newFile" }); setModalFile(true) }}
/>
))
}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
</>
}
)}
</View>
</View>
</ScrollView>
<DrawerBottom animation="slide" isVisible={isModalFile} setVisible={setModalFile} title="Menu">
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile.id, indexDelFile.cat) }}
onPress={() => deleteFile(indexDelFile.id, indexDelFile.cat)}
/>
</View>
</DrawerBottom>
</SafeAreaView>
);
}

View File

@@ -1,11 +1,9 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import BorderBottomItem2 from "@/components/borderBottomItem2";
import HeaderRightDiscussionDetail from "@/components/discussion/headerDiscussionDetail";
import DrawerBottom from "@/components/drawerBottom";
import ImageUser from "@/components/imageNew";
import { InputForm } from "@/components/inputForm";
import LabelStatus from "@/components/labelStatus";
import MenuItemRow from "@/components/menuItemRow";
import ModalConfirmation from "@/components/ModalConfirmation";
import Skeleton from "@/components/skeleton";
@@ -24,7 +22,7 @@ import {
import { getDB } from "@/lib/firebaseDatabase";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather, Ionicons, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { Feather, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { ref } from "@react-native-firebase/database";
import { useHeaderHeight } from '@react-navigation/elements';
import { router, Stack, useLocalSearchParams } from "expo-router";
@@ -87,24 +85,15 @@ export default function DiscussionDetail() {
const [detailMore, setDetailMore] = useState<any>([])
const entities = useSelector((state: any) => state.entities)
const [isVisible, setVisible] = useState(false)
const [selectKomentar, setSelectKomentar] = useState({
id: '',
comment: ''
})
const [selectKomentar, setSelectKomentar] = useState({ id: '', comment: '' })
const [viewEdit, setViewEdit] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
useEffect(() => {
const onValueChange = reference.on('value', snapshot => {
if (snapshot.val() == null) {
reference.set({ trigger: true })
}
if (snapshot.val() == null) { reference.set({ trigger: true }) }
handleLoadComment(false)
});
// Stop listening for updates when no longer required
return () => reference.off('value', onValueChange);
}, []);
@@ -115,23 +104,12 @@ export default function DiscussionDetail() {
});
}
async function handleLoad(loading: boolean) {
try {
setLoading(loading)
const hasil = await decryptToken(String(token?.current));
const response = await apiGetDiscussionOne({
id: detail,
user: hasil,
cat: "data",
});
const responseFile = await apiGetDiscussionOne({
id: detail,
user: hasil,
cat: "file",
});
const response = await apiGetDiscussionOne({ id: detail, user: hasil, cat: "data" });
const responseFile = await apiGetDiscussionOne({ id: detail, user: hasil, cat: "file" });
setData(response.data);
setFileDiscussion(responseFile.data)
setIsCreator(response.data.createdBy == hasil);
@@ -146,11 +124,7 @@ export default function DiscussionDetail() {
try {
setLoadingKomentar(loading)
const hasil = await decryptToken(String(token?.current));
const response = await apiGetDiscussionOne({
id: detail,
user: hasil,
cat: "comment",
});
const response = await apiGetDiscussionOne({ id: detail, user: hasil, cat: "comment" });
setDataComment(response.data);
} catch (error) {
console.error(error);
@@ -162,17 +136,8 @@ export default function DiscussionDetail() {
async function handleCheckMember() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetDivisionOneFeature({
id,
user: hasil,
cat: "check-member",
});
const response2 = await apiGetDivisionOneFeature({
id,
user: hasil,
cat: "check-admin",
});
const response = await apiGetDivisionOneFeature({ id, user: hasil, cat: "check-member" });
const response2 = await apiGetDivisionOneFeature({ id, user: hasil, cat: "check-admin" });
setIsMemberDivision(response.data);
setIsAdminDivision(response2.data);
} catch (error) {
@@ -180,33 +145,18 @@ export default function DiscussionDetail() {
}
}
useEffect(() => {
handleLoad(false);
}, [update.data]);
useEffect(() => {
handleLoad(true)
handleLoadComment(true);
handleCheckMember();
}, []);
useEffect(() => { handleLoad(false); }, [update.data]);
useEffect(() => { handleLoad(true); handleLoadComment(true); handleCheckMember(); }, []);
async function handleKomentar() {
try {
setLoadingSend(true);
const hasil = await decryptToken(String(token?.current));
const response = await apiSendDiscussionCommentar({
id: detail,
data: { comment: komentar, user: hasil },
});
if (response.success) {
setKomentar("")
updateTrigger()
}
const response = await apiSendDiscussionCommentar({ id: detail, data: { comment: komentar, user: hasil } });
if (response.success) { setKomentar(""); updateTrigger() }
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menambahkan komentar"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan komentar" })
} finally {
setLoadingSend(false);
}
@@ -216,20 +166,11 @@ export default function DiscussionDetail() {
try {
setLoadingSend(true);
const hasil = await decryptToken(String(token?.current));
const response = await apiEditDiscussionCommentar({
id: selectKomentar.id,
data: { comment: selectKomentar.comment, user: hasil },
});
if (response.success) {
updateTrigger()
} else {
Toast.show({ type: 'small', text1: response.message })
}
} catch (error : any ) {
const response = await apiEditDiscussionCommentar({ id: selectKomentar.id, data: { comment: selectKomentar.comment, user: hasil } });
if (response.success) { updateTrigger() } else { Toast.show({ type: 'small', text1: response.message }) }
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengedit komentar"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengedit komentar" })
} finally {
setLoadingSend(false);
handleViewEditKomentar()
@@ -240,20 +181,11 @@ export default function DiscussionDetail() {
try {
setLoadingSend(true);
const hasil = await decryptToken(String(token?.current));
const response = await apiDeleteDiscussionCommentar({
id: selectKomentar.id,
data: { user: hasil },
});
if (response.success) {
updateTrigger()
} else {
Toast.show({ type: 'small', text1: response.message })
}
} catch (error : any ) {
const response = await apiDeleteDiscussionCommentar({ id: selectKomentar.id, data: { user: hasil } });
if (response.success) { updateTrigger() } else { Toast.show({ type: 'small', text1: response.message }) }
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menghapus komentar"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menghapus komentar" })
} finally {
setLoadingSend(false)
setVisible(false)
@@ -265,13 +197,11 @@ export default function DiscussionDetail() {
setVisible(true)
}
function handleViewEditKomentar() {
setVisible(false)
setViewEdit(!viewEdit)
}
const handleRefresh = async () => {
setRefreshing(true)
handleLoad(false)
@@ -280,27 +210,15 @@ export default function DiscussionDetail() {
setRefreshing(false)
};
const canWrite = data?.status != 2 && data?.isActive && ((entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision)
const isOpen = data?.status === 1
return (
<>
<Stack.Screen
options={{
// headerLeft: () => (
// <ButtonBackHeader
// onPress={() => {
// router.back();
// }}
// />
// ),
headerTitle: "Diskusi",
headerTitleAlign: "center",
// headerRight: () =>
// (entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision || isCreator ?
// <HeaderRightDiscussionDetail
// id={detail}
// status={data?.status}
// isActive={data?.isActive}
// /> : (<></>)
// ,
header: () => (
<AppHeader
title="Diskusi"
@@ -308,257 +226,177 @@ export default function DiscussionDetail() {
onPressLeft={() => router.back()}
right={
((entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision || isCreator) ?
<HeaderRightDiscussionDetail
id={detail}
status={data?.status}
isActive={data?.isActive}
/> : (<></>)
<HeaderRightDiscussionDetail id={detail} status={data?.status} isActive={data?.isActive} /> : undefined
}
/>
)
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View style={[Styles.flex1, { backgroundColor: colors.background }]}>
<ScrollView
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.icon} />}
>
<View style={[Styles.p15, Styles.mb100]}>
{
loading ?
<SkeletonContent />
:
<BorderBottomItem2
dataFile={fileDiscussion}
descEllipsize={false}
bgColor="white"
borderType="all"
icon={
<ImageUser
src={`${ConstEnv.url_storage}/files/${data?.user_img}`}
size="sm"
/>
}
title={data?.username}
subtitle={
data?.isActive ? (
data?.status == 1 ? (
<LabelStatus category="success" text="BUKA" size="small" />
) : (
<LabelStatus category="error" text="TUTUP" size="small" />
<View style={[Styles.p15]}>
{loading ? (
<SkeletonContent />
) : (
<BorderBottomItem2
dataFile={fileDiscussion}
descEllipsize={false}
borderType="all"
bgColor="white"
icon={
<ImageUser src={`${ConstEnv.url_storage}/files/${data?.user_img}`} size="sm" />
}
title={data?.username}
titleShowAll={true}
subtitle={
<View style={[Styles.discussionStatusPill, {
borderColor: !data?.isActive ? '#F59E0B' : isOpen ? '#10B981' : colors.dimmed + '80',
}]}>
<Text style={[Styles.discussionStatusText, {
color: !data?.isActive ? '#F59E0B' : isOpen ? '#10B981' : colors.dimmed,
}]}>
{!data?.isActive ? 'Arsip' : isOpen ? 'Buka' : 'Tutup'}
</Text>
</View>
}
desc={data?.desc}
leftBottomInfo={
<View style={Styles.rowItemsCenter}>
<Feather name="message-square" size={14} color={colors.dimmed} style={Styles.mr05} />
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>{dataComment.length} Komentar</Text>
</View>
}
rightBottomInfo={<Text style={[Styles.textInformation, { color: colors.dimmed }]}>{data?.createdAt}</Text>}
/>
)}
<View style={Styles.mt10}>
{loadingKomentar ? (
arrSkeleton.map((_, i) => (
<Skeleton key={i} width={100} widthType="percent" height={40} borderRadius={5} />
))
) : (
dataComment.map((item, i) => (
<Pressable
key={i}
onPress={() => {
setDetailMore((prev: any) =>
prev.includes(item.id) ? prev.filter((id: string) => id !== item.id) : [...prev, item.id]
)
) : (
<LabelStatus category="secondary" text="ARSIP" size="small" />
)
}
rightTopInfo={data?.createdAt}
desc={data?.desc}
leftBottomInfo={
<View style={[Styles.rowItemsCenter]}>
<Ionicons
name="chatbox-ellipses-outline"
size={18}
color="grey"
style={Styles.mr05}
/>
<Text style={[Styles.textInformation, { color: colors.dimmed }, Styles.mb05]} >
{dataComment.length} Komentar
}}
onLongPress={() => {
item.idUser == entities.id && data?.status != 2 && data?.isActive && handleMenuKomentar(item.id, item.comment)
}}
style={({ pressed }) => [
Styles.discussionCommentCard,
{ backgroundColor: pressed ? colors.icon + '10' : colors.card, borderColor: colors.icon + '20' }
]}
>
<View style={Styles.flex1}>
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter, Styles.mb05]}>
<View style={[Styles.rowItemsCenter, { gap: 8, flex: 1, marginRight: 8 }]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textMediumSemiBold, { color: colors.text }]} numberOfLines={1}>
{item.username}
</Text>
{item.isEdited && (
<Text style={[Styles.discussionEditedText, { color: colors.dimmed }]}>diedit</Text>
)}
</View>
<Text style={[Styles.discussionDateText, { color: colors.dimmed, flexShrink: 0 }]}>
{item.createdAt}
</Text>
</View>
<Text style={[Styles.textDefault, { color: colors.text }]} numberOfLines={detailMore.includes(item.id) ? 0 : 3}>
{item.comment}
</Text>
</View>
}
/>
}
<View style={[Styles.mt10]}>
{
loadingKomentar ?
arrSkeleton.map((item, index) => (
<Skeleton key={index} width={100} widthType="percent" height={40} borderRadius={5} />
))
:
dataComment.map((item, index) => (
<BorderBottomItem
key={index}
borderType="all"
colorPress
icon={
<ImageUser
src={`${ConstEnv.url_storage}/files/${item.img}`}
size="xs"
/>
}
title={item.username}
rightTopInfo={item.createdAt}
desc={item.comment}
rightBottomInfo={item.isEdited ? "Edited" : ""}
descEllipsize={detailMore.includes(item.id) ? false : true}
bgColor="white"
onPress={() => {
setDetailMore((prev: any) => {
if (prev.includes(item.id)) {
return prev.filter((id: string) => id !== item.id)
} else {
return [...prev, item.id]
}
})
}}
onLongPress={() => {
item.idUser == entities.id && data?.status != 2 && data?.isActive && handleMenuKomentar(item.id, item.comment)
}}
/>
))
}
</Pressable>
))
)}
</View>
</View>
</ScrollView>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={headerHeight}
>
<View
style={[
Styles.contentItemCenter,
Styles.w100,
{ backgroundColor: colors.background },
viewEdit && Styles.borderTop
]}
>
{
viewEdit ?
<>
<View style={[Styles.w90, Styles.rowSpaceBetween, Styles.pv05]}>
<View style={[Styles.rowItemsCenter]}>
<Feather name="edit-3" color={colors.text} size={22} style={[Styles.mh05]} />
<Text style={[Styles.textMediumSemiBold]}>Edit Komentar</Text>
</View>
<Pressable onPress={() => handleViewEditKomentar()}>
<MaterialIcons name="close" color={colors.text} size={22} />
</Pressable>
</View>
<InputForm
bg={colors.card}
type="default"
round
multiline
placeholder="Kirim Komentar"
onChange={(val: string) => setSelectKomentar({ ...selectKomentar, comment: val })}
value={selectKomentar.comment}
itemRight={
<Pressable
onPress={() => {
selectKomentar.comment != "" &&
!regexOnlySpacesOrEnter.test(selectKomentar.comment) &&
!loadingSend &&
data?.status != 2 &&
data?.isActive &&
(((entityUser.role == "user" ||
entityUser.role == "coadmin") &&
isMemberDivision) ||
entityUser.role == "admin" ||
entityUser.role == "supadmin" ||
entityUser.role == "developer" ||
entityUser.role == "cosupadmin") &&
handleEditKomentar();
}}
style={[
Platform.OS == 'android' && Styles.mb12,
]}
>
<MaterialIcons
name="send"
size={25}
style={
[selectKomentar.comment == "" || regexOnlySpacesOrEnter.test(selectKomentar.comment) || loadingSend || ((entityUser.role == "user" || entityUser.role == "coadmin") && !isMemberDivision)
? { color: colors.dimmed }
: { color: colors.tint },
]
}
/>
</Pressable>
}
/>
</>
:
data?.status != 2 && data?.isActive && ((entityUser.role != "user" && entityUser.role != "coadmin") ||
isMemberDivision)
?
<InputForm
type="default"
round
multiline
placeholder="Kirim Komentar"
onChange={setKomentar}
value={komentar}
itemRight={
<Pressable
onPress={() => {
komentar != "" &&
!regexOnlySpacesOrEnter.test(komentar) &&
!loadingSend &&
data?.status != 2 &&
data?.isActive &&
(((entityUser.role == "user" ||
entityUser.role == "coadmin") &&
isMemberDivision) ||
entityUser.role == "admin" ||
entityUser.role == "supadmin" ||
entityUser.role == "developer" ||
entityUser.role == "cosupadmin") &&
handleKomentar();
}}
style={[
Platform.OS == 'android' && Styles.mb12,
]}
>
<MaterialIcons
name="send"
size={25}
style={
[komentar == "" || regexOnlySpacesOrEnter.test(komentar) || loadingSend || ((entityUser.role == "user" || entityUser.role == "coadmin") && !isMemberDivision)
? { color: colors.dimmed }
: { color: colors.tint }
]
}
/>
</Pressable>
}
/>
:
<View style={[Styles.pv20, { alignItems: 'center' }]}>
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>
{
data?.status == 2 ? "Diskusi telah ditutup" : data?.isActive == false ? "Diskusi telah diarsipkan" : "Hanya anggota divisi yang dapat memberikan komentar"
}
</Text>
</View>
}
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={headerHeight}>
<View style={[Styles.contentItemCenter, Styles.w100, { backgroundColor: colors.background }, viewEdit && Styles.borderTop]}>
{viewEdit ? (
<>
<View style={[Styles.w90, Styles.rowSpaceBetween, Styles.pv05]}>
<View style={Styles.rowItemsCenter}>
<Feather name="edit-3" color={colors.text} size={22} style={Styles.mh05} />
<Text style={Styles.textMediumSemiBold}>Edit Komentar</Text>
</View>
<Pressable onPress={() => handleViewEditKomentar()}>
<MaterialIcons name="close" color={colors.text} size={22} />
</Pressable>
</View>
<InputForm
bg={colors.card}
type="default" round multiline
placeholder="Kirim Komentar"
onChange={(val: string) => setSelectKomentar({ ...selectKomentar, comment: val })}
value={selectKomentar.comment}
itemRight={
<Pressable
onPress={() => {
selectKomentar.comment != "" && !regexOnlySpacesOrEnter.test(selectKomentar.comment) && !loadingSend && data?.status != 2 && data?.isActive
&& (((entityUser.role == "user" || entityUser.role == "coadmin") && isMemberDivision) || entityUser.role == "admin" || entityUser.role == "supadmin" || entityUser.role == "developer" || entityUser.role == "cosupadmin")
&& handleEditKomentar();
}}
style={[Platform.OS == 'android' && Styles.mb12]}
>
<MaterialIcons name="send" size={25}
style={[
selectKomentar.comment == "" || regexOnlySpacesOrEnter.test(selectKomentar.comment) || loadingSend || ((entityUser.role == "user" || entityUser.role == "coadmin") && !isMemberDivision)
? { color: colors.dimmed } : { color: colors.tint },
]}
/>
</Pressable>
}
/>
</>
) : canWrite ? (
<InputForm
type="default" round multiline
placeholder="Kirim Komentar"
onChange={setKomentar} value={komentar}
itemRight={
<Pressable
onPress={() => {
komentar != "" && !regexOnlySpacesOrEnter.test(komentar) && !loadingSend && data?.status != 2 && data?.isActive
&& (((entityUser.role == "user" || entityUser.role == "coadmin") && isMemberDivision) || entityUser.role == "admin" || entityUser.role == "supadmin" || entityUser.role == "developer" || entityUser.role == "cosupadmin")
&& handleKomentar();
}}
style={[Platform.OS == 'android' && Styles.mb12]}
>
<MaterialIcons name="send" size={25}
style={[
komentar == "" || regexOnlySpacesOrEnter.test(komentar) || loadingSend || ((entityUser.role == "user" || entityUser.role == "coadmin") && !isMemberDivision)
? { color: colors.dimmed } : { color: colors.tint },
]}
/>
</Pressable>
}
/>
) : (
<View style={[Styles.pv20, Styles.itemsCenter]}>
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>
{data?.status == 2 ? "Diskusi telah ditutup" : data?.isActive == false ? "Diskusi telah diarsipkan" : "Hanya anggota divisi yang dapat memberikan komentar"}
</Text>
</View>
)}
</View>
</KeyboardAvoidingView>
</View>
<DrawerBottom animation="slide" isVisible={isVisible} setVisible={setVisible} title="Komentar">
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<MaterialCommunityIcons name="pencil-outline" color={colors.text} size={25} />}
title="Edit"
onPress={() => { handleViewEditKomentar() }}
/>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => {
setVisible(false)
setTimeout(() => {
setShowDeleteModal(true)
}, 600)
}}
/>
<MenuItemRow icon={<MaterialCommunityIcons name="pencil-outline" color={colors.text} size={25} />} title="Edit" onPress={() => handleViewEditKomentar()} />
<MenuItemRow icon={<MaterialIcons name="delete-outline" color={colors.text} size={25} />} title="Hapus" onPress={() => { setVisible(false); setTimeout(() => setShowDeleteModal(true), 600) }} />
</View>
</DrawerBottom>
@@ -566,10 +404,7 @@ export default function DiscussionDetail() {
visible={showDeleteModal}
title="Konfirmasi"
message="Apakah anda yakin ingin menghapus komentar?"
onConfirm={() => {
setShowDeleteModal(false)
handleDeleteKomentar()
}}
onConfirm={() => { setShowDeleteModal(false); handleDeleteKomentar() }}
onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus"
cancelText="Batal"

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader"
import BorderBottomItem from "@/components/borderBottomItem"
import ButtonSaveHeader from "@/components/buttonSaveHeader"
import ButtonSelect from "@/components/buttonSelect"
import DrawerBottom from "@/components/drawerBottom"
import { InputForm } from "@/components/inputForm"
import LoadingCenter from "@/components/loadingCenter"
@@ -16,10 +14,29 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"
import * as DocumentPicker from "expo-document-picker"
import { router, Stack, useLocalSearchParams } from "expo-router"
import { useState } from "react"
import { SafeAreaView, ScrollView, View } from "react-native"
import { Pressable, SafeAreaView, ScrollView, View } from "react-native"
import Toast from "react-native-toast-message"
import { useDispatch, useSelector } from "react-redux"
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function CreateDiscussionDivision() {
const { colors } = useTheme();
@@ -34,76 +51,55 @@ export default function CreateDiscussionDivision() {
const [indexDelFile, setIndexDelFile] = useState<number>(0)
const pickDocumentAsync = async () => {
let result = await DocumentPicker.getDocumentAsync({
type: ["*/*"],
multiple: true
});
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (!result.canceled) {
for (let i = 0; i < result.assets?.length; i++) {
if (result.assets[i].uri) {
setFileForm((prev) => [...prev, result.assets[i]])
let skipped = 0
for (const asset of result.assets) {
if (!asset.uri) continue
if (fileForm.some(f => f.name === asset.name)) {
skipped++
} else {
setFileForm(prev => [...prev, asset])
}
}
if (skipped > 0) Toast.show({ type: 'small', text1: 'Beberapa file sudah ditambahkan' })
}
};
function deleteFile(index: number) {
setFileForm([...fileForm.filter((val, i) => i !== index)])
setFileForm(fileForm.filter((_, i) => i !== index))
setModalFile(false)
}
async function handleCreate() {
try {
setLoading(true)
const hasil = await decryptToken(String(token?.current))
const fd = new FormData()
for (let i = 0; i < fileForm.length; i++) {
fd.append(`file${i}`, {
uri: fileForm[i].uri,
type: 'application/octet-stream',
name: fileForm[i].name,
} as any);
fd.append(`file${i}`, { uri: fileForm[i].uri, type: 'application/octet-stream', name: fileForm[i].name } as any);
}
fd.append("data", JSON.stringify(
{ user: hasil, desc, idDivision: id }
))
fd.append("data", JSON.stringify({ user: hasil, desc, idDivision: id }))
const response = await apiCreateDiscussion(fd)
// const response = await apiCreateDiscussion({ data: { user: hasil, desc, idDivision: id } })
if (response.success) {
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data', })
Toast.show({ type: 'small', text1: 'Berhasil menambahkan data' })
dispatch(setUpdateDiscussion({ ...update, data: !update.data }));
router.back()
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menambahkan data"
Toast.show({ type: 'small', text1: message })
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menambahkan data" })
} finally {
setLoading(false)
}
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
// headerLeft: () => <ButtonBackHeader onPress={() => { router.back() }} />,
headerTitle: 'Tambah Diskusi',
headerTitleAlign: 'center',
// headerRight: () => <ButtonSaveHeader
// disable={desc == "" || loading}
// category="create"
// onPress={() => {
// handleCreate()
// }} />
header: () => (
<AppHeader
title="Tambah Diskusi"
@@ -113,16 +109,15 @@ export default function CreateDiscussionDivision() {
<ButtonSaveHeader
disable={desc == "" || loading}
category="create"
onPress={() => {
handleCreate()
}} />
onPress={() => handleCreate()}
/>
}
/>
)
}}
/>
{loading && <LoadingCenter />}
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100]}>
<ScrollView showsVerticalScrollIndicator={false} style={[Styles.h100, Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15, Styles.mb100]}>
<InputForm
label="Diskusi"
@@ -133,32 +128,56 @@ export default function CreateDiscussionDivision() {
multiline
bg={colors.card}
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
{
fileForm.length > 0
&&
<>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>File</Text>
<Text style={[Styles.textDefault]}>{fileForm.length} file</Text>
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, Styles.mb10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType={fileForm.length - 1 == index ? "none" : "bottom"}
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
/>
))
}
</View>
</>
}
{/* File */}
<View style={[Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: fileForm.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{fileForm.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{fileForm.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{fileForm.length} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{fileForm.length > 0 && (
<View style={Styles.fileGrid}>
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={index}
onPress={() => { setIndexDelFile(index); setModalFile(true) }}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
)}
</View>
</View>
</ScrollView>
@@ -167,10 +186,10 @@ export default function CreateDiscussionDivision() {
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => { deleteFile(indexDelFile) }}
onPress={() => deleteFile(indexDelFile)}
/>
</View>
</DrawerBottom>
</SafeAreaView>
)
}
}

View File

@@ -1,23 +1,23 @@
import BorderBottomItem from "@/components/borderBottomItem";
import GuideOverlay from "@/components/GuideOverlay";
import ButtonTab from "@/components/buttonTab";
import ImageUser from "@/components/imageNew";
import InputSearch from "@/components/inputSearch";
import LabelStatus from "@/components/labelStatus";
import SkeletonContent from "@/components/skeletonContent";
import Text from "@/components/Text";
import WrapTab from "@/components/wrapTab";
import { ConstEnv } from "@/constants/ConstEnv";
import Styles from "@/constants/Styles";
import { apiGetDiscussion, apiGetDivisionOneFeature } from "@/lib/api";
import { GUIDE_DIVISION_DISCUSSION } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather, Ionicons } from "@expo/vector-icons";
import { AntDesign, Feather } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { RefreshControl, View, VirtualizedList } from "react-native";
import { FlatList, Pressable, RefreshControl, View } from "react-native";
import { useSelector } from "react-redux";
type Props = {
id: string,
title: string,
@@ -30,7 +30,6 @@ type Props = {
isActive: boolean
}
export default function DiscussionDivision() {
const { colors } = useTheme();
const { id, active } = useLocalSearchParams<{ id: string, active?: string }>()
@@ -47,21 +46,13 @@ export default function DiscussionDivision() {
const [isMemberDivision, setIsMemberDivision] = useState(false)
const [isAdminDivision, setIsAdminDivision] = useState(false)
const entityUser = useSelector((state: any) => state.user)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-discussion')
async function handleCheckMember() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetDivisionOneFeature({
id,
user: hasil,
cat: "check-member",
});
const response2 = await apiGetDivisionOneFeature({
id,
user: hasil,
cat: "check-admin",
});
const response = await apiGetDivisionOneFeature({ id, user: hasil, cat: "check-member" });
const response2 = await apiGetDivisionOneFeature({ id, user: hasil, cat: "check-admin" });
setIsMemberDivision(response.data);
setIsAdminDivision(response2.data);
} catch (error) {
@@ -80,8 +71,6 @@ export default function DiscussionDivision() {
setData(response.data)
} else if (thisPage > 1 && response.data.length > 0) {
setData([...data, ...response.data])
} else {
return;
}
} catch (error) {
console.error(error)
@@ -91,26 +80,15 @@ export default function DiscussionDivision() {
}
}
useEffect(() => {
handleLoad(false, 1)
}, [update.data])
useEffect(() => {
handleLoad(true, 1)
}, [status, search])
useEffect(() => { handleLoad(false, 1) }, [update.data])
useEffect(() => { handleLoad(true, 1) }, [status, search])
useEffect(() => { handleCheckMember() }, [])
const loadMoreData = () => {
if (waiting) return
setTimeout(() => {
handleLoad(false, page + 1)
}, 1000);
setTimeout(() => { handleLoad(false, page + 1) }, 1000);
}
useEffect(() => {
handleCheckMember()
}, [])
const handleRefresh = async () => {
setRefreshing(true)
handleLoad(false, 1)
@@ -118,100 +96,115 @@ export default function DiscussionDivision() {
setRefreshing(false)
};
const getItem = (_data: unknown, index: number): Props => ({
id: data[index].id,
title: data[index].title,
desc: data[index].desc,
status: data[index].status,
user_name: data[index].user_name,
img: data[index].img,
total_komentar: data[index].total_komentar,
createdAt: data[index].createdAt,
isActive: data[index].isActive,
})
const isOpen = (item: Props) => item.status === 1
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '20' },
cardPressed: { backgroundColor: colors.icon + '10' },
title: { color: colors.text },
dimmed: { color: colors.dimmed },
statusOpen: { borderColor: '#10B981' as const },
statusClosed: { borderColor: colors.dimmed + '80' },
statusTextOpen: { color: '#10B981' as const },
statusTextClosed: { color: colors.dimmed },
}
return (
<View style={[Styles.p15, { flex: 1, backgroundColor: colors.background }]}>
{
((entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision) &&
<View>
<View style={[Styles.flex1, themed.background]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_DIVISION_DISCUSSION} onDismiss={dismissGuide} />
{((entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision) && (
<View style={[Styles.ph15, Styles.discussionHeaderPadding]}>
<WrapTab>
<ButtonTab
active={status == "false" ? "false" : "true"}
value="true"
onPress={() => { setStatus("true") }}
onPress={() => setStatus("true")}
label="Aktif"
icon={<Feather name="check-circle" color={status == "false" ? colors.dimmed : 'white'} size={20} />}
n={2} />
n={2}
/>
<ButtonTab
active={status == "false" ? "false" : "true"}
value="false"
onPress={() => { setStatus("false") }}
onPress={() => setStatus("false")}
label="Arsip"
icon={<AntDesign name="closecircleo" color={status == "true" ? colors.dimmed : 'white'} size={20} />}
n={2} />
n={2}
/>
</WrapTab>
<InputSearch onChange={setSearch} />
</View>
}
)}
<View style={[Styles.flex1, Styles.ph15, Styles.discussionListPadding]}>
{loading ? (
arrSkeleton.map((_, i) => <SkeletonContent key={i} />)
) : data.length === 0 ? (
<View style={[Styles.contentItemCenter, Styles.mt30]}>
<Feather name="message-circle" size={42} color={colors.icon + '40'} />
<Text style={[Styles.mt10, Styles.discussionEmptyText, themed.dimmed]}>
Tidak ada diskusi
</Text>
</View>
) : (
<FlatList
data={data}
keyExtractor={(_, i) => String(i)}
showsVerticalScrollIndicator={false}
onEndReached={loadMoreData}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.icon} />
}
ItemSeparatorComponent={() => <View style={Styles.discussionSeparator} />}
renderItem={({ item }: { item: Props }) => (
<Pressable
onPress={() => router.push(`./discussion/${item.id}`)}
style={({ pressed }) => [
Styles.discussionCard,
themed.card,
pressed && themed.cardPressed,
]}
>
<View style={[Styles.rowItemsCenter, Styles.mb08]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<View style={[Styles.flex1, Styles.discussionTitleCol]}>
<Text style={[Styles.textDefaultSemiBold, themed.title]} numberOfLines={1}>
{item.user_name}
</Text>
{status === "true" && (
<View style={[Styles.discussionStatusPill, isOpen(item) ? themed.statusOpen : themed.statusClosed]}>
<Text style={[Styles.discussionStatusText, isOpen(item) ? themed.statusTextOpen : themed.statusTextClosed]}>
{isOpen(item) ? 'Buka' : 'Tutup'}
</Text>
</View>
)}
</View>
</View>
<View style={[{ flex: 2 }, Styles.mt05]}>
{
loading ?
arrSkeleton.map((item: any, i: number) => {
return (
<SkeletonContent key={i} />
)
})
:
data.length > 0 ?
<VirtualizedList
data={data}
getItemCount={() => data.length}
getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => {
return (
<BorderBottomItem
key={index}
onPress={() => { router.push(`./discussion/${item.id}`) }}
borderType="bottom"
icon={
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
}
title={item.user_name}
subtitle={
status == "true" ? item.status == 1 ? <LabelStatus category='success' text='BUKA' size="small" /> : <LabelStatus category='error' text='TUTUP' size="small" /> : <></>
}
rightTopInfo={item.createdAt}
desc={item.desc}
leftBottomInfo={
<View style={[Styles.rowItemsCenter]}>
<Ionicons name="chatbox-ellipses-outline" size={18} color={colors.dimmed} style={Styles.mr05} />
<Text style={[Styles.textInformation, { color: colors.dimmed }, Styles.mb05]}>Diskusikan</Text>
</View>
}
rightBottomInfo={item.total_komentar + ' Komentar'}
bgColor="transparent"
/>
)
}}
keyExtractor={(item, index) => String(index)}
onEndReached={loadMoreData}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
/>
:
(<Text style={[Styles.textDefault, Styles.mv10, { textAlign: "center", color: colors.dimmed }]}>Tidak ada diskusi</Text>)
}
{item.desc ? (
<Text style={[Styles.textMediumNormal, Styles.discussionCardIndent, Styles.discussionDescMargin, themed.title]} numberOfLines={2}>
{item.desc}
</Text>
) : null}
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.discussionCardIndent]}>
<View style={Styles.rowItemsCenter}>
<Feather name="message-square" size={14} color={colors.dimmed} />
<Text style={[Styles.discussionCommentText, themed.dimmed]}>
{item.total_komentar} Komentar
</Text>
</View>
<Text style={[Styles.discussionDateText, themed.dimmed]}>
{item.createdAt}
</Text>
</View>
</Pressable>
)}
/>
)}
</View>
</View>
);
}
}

View File

@@ -1,3 +1,4 @@
import GuideOverlay from "@/components/GuideOverlay";
import ModalConfirmation from "@/components/ModalConfirmation";
import AppHeader from "@/components/AppHeader";
import { ButtonHeader } from "@/components/buttonHeader";
@@ -22,6 +23,8 @@ import {
apiShareDocument,
} from "@/lib/api";
import { setUpdateDokumen } from "@/lib/dokumenUpdate";
import { GUIDE_DIVISION_DOCUMENT } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import {
@@ -90,6 +93,7 @@ export default function DocumentDivision() {
const [isMemberDivision, setIsMemberDivision] = useState(false)
const entityUser = useSelector((state: any) => state.user)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-document')
const [bodyRename, setBodyRename] = useState({
id: "",
name: "",
@@ -415,6 +419,7 @@ export default function DocumentDivision() {
)
}}
/>
<GuideOverlay visible={guideVisible} steps={GUIDE_DIVISION_DOCUMENT} onDismiss={dismissGuide} />
<ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} />
<ScrollView
refreshControl={

View File

@@ -206,6 +206,8 @@ export default function TaskDivisionAddTask() {
day_label: { color: colors.text },
time_label: { color: colors.text },
weekday_label: { color: colors.text },
button_next_image: { tintColor: colors.text },
button_prev_image: { tintColor: colors.text },
}}
/>
</View>
@@ -238,7 +240,7 @@ export default function TaskDivisionAddTask() {
>
<Text style={[dsbButton ? Styles.cGray : Styles.cWhite]}>Detail</Text>
</Pressable> */}
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} />
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} disabled={from == "" || to == ""} />
</View>
<InputForm
label="Judul Tugas"

View File

@@ -1,4 +1,5 @@
import AppHeader from "@/components/AppHeader";
import GuideOverlay from "@/components/GuideOverlay";
import SectionCancel from "@/components/sectionCancel";
import SectionProgress from "@/components/sectionProgress";
import HeaderRightTaskDetail from "@/components/task/headerTaskDetail";
@@ -9,6 +10,8 @@ import SectionReportTask from "@/components/task/sectionReportTask";
import SectionTanggalTugasTask from "@/components/task/sectionTanggalTugasTask";
import Styles from "@/constants/Styles";
import { apiGetDivisionOneFeature, apiGetTaskOne } from "@/lib/api";
import { GUIDE_PROJECT_DETAIL } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { router, Stack, useLocalSearchParams } from "expo-router";
@@ -23,6 +26,7 @@ type Props = {
reason: string
status: number
isActive: boolean
idGroup: string
}
export default function DetailTaskDivision() {
@@ -32,9 +36,11 @@ export default function DetailTaskDivision() {
const [data, setData] = useState<Props>()
const [loading, setLoading] = useState(true)
const [progress, setProgress] = useState(0)
const [taskStats, setTaskStats] = useState<{ done: number, total: number } | undefined>()
const update = useSelector((state: any) => state.taskUpdate)
const [refreshing, setRefreshing] = useState(false)
const [isMemberDivision, setIsMemberDivision] = useState(false);
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('division-task-detail')
const [isAdminDivision, setIsAdminDivision] = useState(false);
const entityUser = useSelector((state: any) => state.user);
@@ -65,6 +71,17 @@ export default function DetailTaskDivision() {
}, [])
async function handleLoadTaskStats() {
try {
const hasil = await decryptToken(String(token?.current))
const response = await apiGetTaskOne({ id: detail, user: hasil, cat: 'task' })
const tasks: { status: number }[] = response.data
setTaskStats({ done: tasks.filter(t => t.status === 1).length, total: tasks.length })
} catch (error) {
console.error(error)
}
}
async function handleLoad(cat: 'data' | 'progress') {
try {
if (cat == 'data') setLoading(true)
@@ -90,10 +107,15 @@ export default function DetailTaskDivision() {
handleLoad('progress')
}, [update.progress])
useEffect(() => {
handleLoadTaskStats()
}, [update.task])
const handleRefresh = async () => {
setRefreshing(true)
await handleLoad('data')
await handleLoad('progress')
await handleLoadTaskStats()
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false)
};
@@ -122,6 +144,7 @@ export default function DetailTaskDivision() {
)
}}
/>
<GuideOverlay visible={guideVisible} steps={GUIDE_PROJECT_DETAIL} onDismiss={dismissGuide} />
<ScrollView
refreshControl={
<RefreshControl
@@ -135,9 +158,9 @@ export default function DetailTaskDivision() {
{
data?.reason != null && data?.reason != "" && <SectionCancel text={data?.reason} />
}
<SectionProgress text={`Kemajuan Kegiatan ${progress}%`} progress={progress} />
<SectionProgress progress={progress} doneCount={taskStats?.done} totalCount={taskStats?.total} />
<SectionReportTask refreshing={refreshing} />
<SectionTanggalTugasTask refreshing={refreshing} isMemberDivision={isMemberDivision} />
<SectionTanggalTugasTask refreshing={refreshing} isMemberDivision={isMemberDivision} isAdminDivision={isAdminDivision} status={data?.status} idGroup={data?.idGroup ?? ''} />
<SectionFileTask refreshing={refreshing} isMemberDivision={isMemberDivision} />
<SectionLinkTask refreshing={refreshing} isMemberDivision={isMemberDivision} />
<SectionMemberTask refreshing={refreshing} isAdminDivision={isAdminDivision} />

View File

@@ -0,0 +1,382 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import { ButtonForm } from "@/components/buttonForm";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import ModalConfirmation from "@/components/ModalConfirmation";
import ModalLoading from "@/components/modalLoading";
import MenuItemRow from "@/components/menuItemRow";
import Skeleton from "@/components/skeleton";
import Text from "@/components/Text";
import { ConstEnv } from "@/constants/ConstEnv";
import Styles from "@/constants/Styles";
import {
apiAddTugasTaskFile,
apiDeleteTugasTaskFile,
apiGetTaskOne,
apiGetTugasTaskFile,
apiLinkTugasTaskFile,
} from "@/lib/api";
import { setUpdateTask } from "@/lib/taskUpdate";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import * as FileSystem from "expo-file-system";
import { startActivityAsync } from "expo-intent-launcher";
import { router, Stack, useLocalSearchParams } from "expo-router";
import * as Sharing from "expo-sharing";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Platform,
SafeAreaView,
ScrollView,
View,
} from "react-native";
import * as mime from "react-native-mime-types";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
type FileItem = {
id: string; // DivisionProjectTaskFile.id
idFile: string; // DivisionProjectFile.id
name: string;
extension: string;
idStorage: string;
};
type ProjectFile = {
id: string;
name: string;
extension: string;
idStorage: string;
};
export default function TugasFileScreen() {
const { colors } = useTheme();
const { id, detail, taskId, member: memberParam } = useLocalSearchParams<{
id: string;
detail: string;
taskId: string;
member: string;
}>();
const { token, decryptToken } = useAuthSession();
const dispatch = useDispatch();
const update = useSelector((state: any) => state.taskUpdate);
const entityUser = useSelector((state: any) => state.user);
const isMember = memberParam === "true";
const canEdit = isMember || (entityUser.role !== "user" && entityUser.role !== "coadmin");
const [data, setData] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadingOpen, setLoadingOpen] = useState(false);
const [loadingUpload, setLoadingUpload] = useState(false);
const [loadingLink, setLoadingLink] = useState(false);
const [selectFile, setSelectFile] = useState<FileItem | null>(null);
const [isMenuModal, setMenuModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [projectFiles, setProjectFiles] = useState<ProjectFile[]>([]);
const [isPickerModal, setPickerModal] = useState(false);
const [loadingProjectFiles, setLoadingProjectFiles] = useState(false);
const [selectedProjectFiles, setSelectedProjectFiles] = useState<string[]>([]);
const arrSkeleton = Array.from({ length: 4 });
async function loadFiles() {
try {
setLoading(true);
const hasil = await decryptToken(String(token?.current));
const response = await apiGetTugasTaskFile({ user: hasil, id: taskId });
setData(response.data ?? []);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}
async function loadProjectFiles() {
try {
setLoadingProjectFiles(true);
const hasil = await decryptToken(String(token?.current));
const response = await apiGetTaskOne({ id: detail, user: hasil, cat: "file" });
setProjectFiles(response.data ?? []);
} catch (error) {
console.error(error);
} finally {
setLoadingProjectFiles(false);
}
}
useEffect(() => {
loadFiles();
}, []);
const openFile = () => {
setMenuModal(false);
setLoadingOpen(true);
const remoteUrl = ConstEnv.url_storage + "/files/" + selectFile?.idStorage;
const fileName = selectFile?.name + "." + selectFile?.extension;
const localPath = `${FileSystem.documentDirectory}/${fileName}`;
const mimeType = mime.lookup(fileName);
FileSystem.downloadAsync(remoteUrl, localPath).then(async ({ uri }) => {
const contentURL = await FileSystem.getContentUriAsync(uri);
try {
if (Platform.OS === "android") {
await startActivityAsync("android.intent.action.VIEW", {
data: contentURL,
flags: 1,
type: mimeType as string,
});
} else {
Sharing.shareAsync(localPath);
}
} catch {
Alert.alert("INFO", "Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini");
} finally {
setLoadingOpen(false);
}
});
};
async function handleDelete() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiDeleteTugasTaskFile({ user: hasil }, String(selectFile?.id));
if (response.success) {
Toast.show({ type: "small", text1: "Berhasil menghapus file" });
dispatch(setUpdateTask({ ...update, task: !update.task }));
loadFiles();
} else {
Toast.show({ type: "small", text1: response.message });
}
} catch (error: any) {
const message = error?.response?.data?.message || "Gagal menghapus file";
Toast.show({ type: "small", text1: message });
} finally {
setMenuModal(false);
}
}
async function handleUpload() {
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (result.canceled) return;
try {
setLoadingUpload(true);
const hasil = await decryptToken(String(token?.current));
const fd = new FormData();
for (let i = 0; i < result.assets.length; i++) {
fd.append(`file${i}`, {
uri: result.assets[i].uri,
type: "application/octet-stream",
name: result.assets[i].name,
} as any);
}
fd.append("data", JSON.stringify({ user: hasil }));
const response = await apiAddTugasTaskFile({ data: fd, id: taskId });
if (response.success) {
Toast.show({ type: "small", text1: "Berhasil menambahkan file" });
dispatch(setUpdateTask({ ...update, task: !update.task }));
loadFiles();
} else {
Toast.show({ type: "small", text1: response.message });
}
} catch (error: any) {
const message = error?.response?.data?.message || "Gagal menambahkan file";
Toast.show({ type: "small", text1: message });
} finally {
setLoadingUpload(false);
}
}
function toggleProjectFileSelect(id: string) {
setSelectedProjectFiles((prev) =>
prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]
);
}
async function handleLinkFiles() {
if (selectedProjectFiles.length === 0) return;
try {
setLoadingLink(true);
const hasil = await decryptToken(String(token?.current));
for (const idFile of selectedProjectFiles) {
await apiLinkTugasTaskFile({ user: hasil, idFile, id: taskId });
}
Toast.show({ type: "small", text1: "Berhasil menambahkan file" });
dispatch(setUpdateTask({ ...update, task: !update.task }));
setPickerModal(false);
setSelectedProjectFiles([]);
loadFiles();
} catch (error: any) {
const message = error?.response?.data?.message || "Gagal menambahkan file";
Toast.show({ type: "small", text1: message });
} finally {
setLoadingLink(false);
}
}
const attachedFileIds = new Set(data.map((f) => f.idFile));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<Stack.Screen
options={{
header: () => (
<AppHeader
title="File Tugas"
showBack={true}
onPressLeft={() => router.back()}
/>
),
}}
/>
<ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} />
<ScrollView>
<View style={[Styles.p15, Styles.mb100]}>
{canEdit && (
<>
<ButtonSelect
value="Upload dari Perangkat"
onPress={handleUpload}
disabled={loadingUpload}
/>
<ButtonSelect
value="Pilih dari File Kegiatan ini"
onPress={() => {
setSelectedProjectFiles([]);
setPickerModal(true);
loadProjectFiles();
}}
/>
</>
)}
{loadingUpload && <ActivityIndicator size="small" style={Styles.mv05} />}
<View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>File Terlampir</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{loading ? (
arrSkeleton.map((_, index) => (
<Skeleton key={index} width={100} height={40} widthType="percent" borderRadius={10} />
))
) : data.length > 0 ? (
data.map((item, index) => (
<BorderBottomItem
key={index}
borderType="all"
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + "." + item.extension}
titleWeight="normal"
onPress={() => {
setSelectFile(item);
setMenuModal(true);
}}
/>
))
) : (
<Text style={[Styles.textDefault, { textAlign: "center", color: colors.dimmed }]}>
Tidak ada file
</Text>
)}
</View>
</View>
</View>
</ScrollView>
{/* Menu per file */}
<DrawerBottom animation="slide" isVisible={isMenuModal} setVisible={setMenuModal} title="Menu">
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<MaterialCommunityIcons name="file-eye" color={colors.text} size={25} />}
title="Lihat / Share"
onPress={openFile}
/>
{canEdit && (
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => {
setMenuModal(false);
setTimeout(() => setShowDeleteModal(true), 600);
}}
/>
)}
</View>
</DrawerBottom>
<ModalConfirmation
visible={showDeleteModal}
title="Konfirmasi"
message="Apakah Anda yakin ingin menghapus file ini?"
onConfirm={() => {
setShowDeleteModal(false);
handleDelete();
}}
onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus"
cancelText="Batal"
/>
{/* Picker file dari proyek */}
<DrawerBottom
animation="slide"
isVisible={isPickerModal}
setVisible={setPickerModal}
title="Pilih File Proyek"
height={60}
>
<ScrollView>
{loadingProjectFiles ? (
<ActivityIndicator size="small" />
) : projectFiles.length > 0 ? (
projectFiles.map((item, index) => {
const isAttached = attachedFileIds.has(item.id);
const isSelected = selectedProjectFiles.includes(item.id);
return (
<View key={index} style={isAttached ? { opacity: 0.4 } : undefined}>
<BorderBottomItem
borderType="bottom"
icon={
isAttached || isSelected ? (
<Ionicons name="checkmark-circle" size={25} color={colors.primary} />
) : (
<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />
)
}
title={item.name + "." + item.extension}
titleWeight="normal"
onPress={() => !isAttached && toggleProjectFileSelect(item.id)}
bgColor="transparent"
/>
</View>
);
})
) : (
<Text style={[Styles.textDefault, { textAlign: "center", color: colors.dimmed }]}>
Tidak ada file tersedia
</Text>
)}
</ScrollView>
{projectFiles.length > 0 && (
<View>
<ButtonForm
text={loadingLink ? "Menyimpan..." : `Tambahkan (${selectedProjectFiles.length})`}
disabled={selectedProjectFiles.length === 0 || loadingLink}
onPress={handleLinkFiles} />
</View>
)}
</DrawerBottom>
</SafeAreaView>
);
}

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import ImageUser from "@/components/imageNew";
import { InputForm } from "@/components/inputForm";
@@ -21,11 +19,31 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { SafeAreaView, ScrollView, View } from "react-native";
import { Pressable, SafeAreaView, ScrollView, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function CreateTaskDivision() {
const { colors } = useTheme();
const { id } = useLocalSearchParams();
@@ -168,59 +186,131 @@ export default function CreateTaskDivision() {
bg={colors.card}
errorText="Judul Tugas tidak boleh kosong"
/>
<ButtonSelect value="Tambah Tanggal & Tugas" onPress={() => { router.push(`/division/${id}/task/create/task`); }} />
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
<ButtonSelect value="Tambah Anggota" onPress={() => { router.push(`/division/${id}/task/create/member`); }} />
<SectionListAddTask />
{
fileForm.length > 0 && (
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>File</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType="all"
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
onPress={() => { setIndexDelFile(index); setModal(true) }}
/>
))
}
</View>
</View>
)
}
{entitiesMember.length > 0 && (
<View>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text>Anggota</Text>
<Text>Total {entitiesMember.length} Anggota</Text>
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, { backgroundColor: colors.card, borderColor: colors.background }]}>
{entitiesMember.map(
(item: { img: any; name: any }, index: any) => {
return (
<BorderBottomItem
key={index}
borderType="bottom"
icon={
<ImageUser
src={`${ConstEnv.url_storage}/files/${item.img}`}
size="sm"
/>
}
title={item.name}
/>
);
}
{/* Tanggal & Tugas */}
<View style={[
Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }
]}>
<Pressable
onPress={() => router.push(`/division/${id}/task/create/task`)}
style={[Styles.sectionActionRow, { marginBottom: taskCreate.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialCommunityIcons name="calendar-check-outline" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Tanggal & Tugas</Text>
{taskCreate.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada tugas ditambahkan</Text>
)}
</View>
</View>
)}
{taskCreate.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{taskCreate.length} tugas</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{taskCreate.length > 0 && <SectionListAddTask showTitle={false} />}
</View>
{/* File */}
<View style={[
Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }
]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: fileForm.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{fileForm.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{fileForm.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{fileForm.length} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{fileForm.length > 0 && (
<View style={Styles.fileGrid}>
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={index}
onPress={() => { setIndexDelFile(index); setModal(true) }}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
)}
</View>
{/* Anggota */}
<View style={[
Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }
]}>
<Pressable
onPress={() => router.push(`/division/${id}/task/create/member`)}
style={[Styles.sectionActionRow, { marginBottom: entitiesMember.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialCommunityIcons name="account-group-outline" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Anggota</Text>
{entitiesMember.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada anggota dipilih</Text>
)}
</View>
{entitiesMember.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{entitiesMember.length} orang</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{entitiesMember.length > 0 && (
<View style={{ gap: 6 }}>
{entitiesMember.map((item: { img: any; name: any; position?: string }, index: any) => (
<View
key={index}
style={[Styles.listItemCard, { borderColor: colors.icon + '18' }]}
>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>{item.name}</Text>
{item.position && (
<View style={[Styles.positionBadge, { backgroundColor: colors.dimmed + '15' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]} numberOfLines={1}>{item.position}</Text>
</View>
)}
</View>
))}
</View>
)}
</View>
</View>
</ScrollView>

View File

@@ -177,6 +177,8 @@ export default function CreateTaskAddTugas() {
day_label: { color: colors.text },
time_label: { color: colors.text },
weekday_label: { color: colors.text },
button_next_image: { tintColor: colors.text },
button_prev_image: { tintColor: colors.text },
}}
/>
</View>
@@ -209,7 +211,7 @@ export default function CreateTaskAddTugas() {
>
<Text style={[dsbButton ? Styles.cGray : Styles.cWhite]}>Detail</Text>
</Pressable> */}
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} />
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} disabled={from == "" || to == ""} />
</View>
<InputForm
label="Judul Tugas"

View File

@@ -21,6 +21,7 @@ import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { Pressable, RefreshControl, ScrollView, View, VirtualizedList } from "react-native";
import { useSelector } from "react-redux";
import AsyncStorage from "@react-native-async-storage/async-storage";
type Props = {
id: string;
@@ -36,6 +37,18 @@ export default function ListTask() {
const { id, status, year } = useLocalSearchParams<{ id: string; status: string; year: string }>()
const [isList, setList] = useState(false)
const { token, decryptToken } = useAuthSession()
useEffect(() => {
AsyncStorage.getItem('division_view_mode').then((val) => {
if (val !== null) setList(val === 'list')
})
}, [])
function toggleView() {
const next = !isList
setList(next)
AsyncStorage.setItem('division_view_mode', next ? 'list' : 'grid')
}
const [data, setData] = useState<Props[]>([])
const [search, setSearch] = useState("")
const update = useSelector((state: any) => state.taskUpdate)
@@ -172,13 +185,9 @@ export default function ListTask() {
n={4}
/>
</ScrollView>
<View style={[Styles.rowSpaceBetween]}>
<View style={[Styles.rowSpaceBetween, { alignItems: 'center' }]}>
<InputSearch width={68} onChange={setSearch} />
<Pressable
onPress={() => {
setList(!isList);
}}
>
<Pressable onPress={toggleView}>
<MaterialCommunityIcons
name={isList ? "format-list-bulleted" : "view-grid"}
color={colors.text}
@@ -219,9 +228,10 @@ export default function ListTask() {
router.push(`./task/${item.id}`);
}}
borderType="bottom"
bgColor="transparent"
icon={
<View style={[Styles.iconContent, ColorsStatus.lightGreen]}>
<AntDesign name="areachart" size={25} color={"#384288"} />
<View style={[Styles.iconContent]}>
<AntDesign name="areachart" size={25} color={"black"} />
</View>
}
title={item.title}

View File

@@ -255,6 +255,8 @@ export default function UpdateProjectTaskDivision() {
day_label: { color: colors.text },
time_label: { color: colors.text },
weekday_label: { color: colors.text },
button_next_image: { tintColor: colors.text },
button_prev_image: { tintColor: colors.text },
}}
/>
)}
@@ -290,7 +292,7 @@ export default function UpdateProjectTaskDivision() {
>
<Text style={[dsbButton ? Styles.cGray : Styles.cWhite]}>Detail</Text>
</Pressable> */}
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} />
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} disabled={from == "" || to == ""} />
</View>
<InputForm
label="Judul Tugas"

View File

@@ -169,7 +169,7 @@ export default function AddMemberDivision() {
return (
<Pressable
key={index}
style={[Styles.itemSelectModal]}
style={[Styles.itemSelectModal, { borderBottomColor: colors.icon + '20' }]}
onPress={() => {
!found && onChoose(item.id, item.name, item.img)
}}

View File

@@ -1,20 +1,17 @@
import ModalConfirmation from "@/components/ModalConfirmation"
import AppHeader from "@/components/AppHeader"
import BorderBottomItem from "@/components/borderBottomItem"
import HeaderRightDivisionInfo from "@/components/division/headerDivisionInfo"
import DrawerBottom from "@/components/drawerBottom"
import HeaderRightDivisionInfo from "@/components/division/headerDivisionInfo"
import ImageUser from "@/components/imageNew"
import MenuItemRow from "@/components/menuItemRow"
import ModalConfirmation from "@/components/ModalConfirmation"
import SectionCancel from "@/components/sectionCancel"
import Skeleton from "@/components/skeleton"
import SkeletonTwoItem from "@/components/skeletonTwoItem"
import Text from "@/components/Text"
import { ColorsStatus } from "@/constants/ColorsStatus"
import { ConstEnv } from "@/constants/ConstEnv"
import Styles from "@/constants/Styles"
import { apiDeleteMemberDivision, apiGetDivisionOneDetail, apiGetDivisionOneFeature, apiUpdateStatusAdminDivision } from "@/lib/api"
import { useAuthSession } from "@/providers/AuthProvider"
import { useTheme } from "@/providers/ThemeProvider"
import { Feather, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons"
import { router, Stack, useLocalSearchParams } from "expo-router"
import { useEffect, useState } from "react"
import { Pressable, RefreshControl, SafeAreaView, ScrollView, View } from "react-native"
@@ -50,8 +47,8 @@ export default function InformationDivision() {
const [dataMember, setDataMember] = useState<PropsMember[]>([])
const [refresh, setRefresh] = useState(false)
const update = useSelector((state: any) => state.divisionUpdate)
const arrSkeleton = Array.from({ length: 5 }, (_, index) => index)
const [loading, setLoading] = useState(true)
const SKELETON_COUNT = 5
const [isMemberDivision, setIsMemberDivision] = useState(false)
const [isAdminDivision, setIsAdminDivision] = useState(false)
const [dataMemberChoose, setDataMemberChoose] = useState({
@@ -186,109 +183,123 @@ export default function InformationDivision() {
}}
/>
<ScrollView
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor={colors.icon} />}
style={[Styles.h100, { backgroundColor: colors.background }]}
>
<View style={[Styles.p15]}>
{
dataDetail?.isActive == false && (
<SectionCancel title={'Divisi nonaktif'} />
)
}
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mb05]}>Deskripsi Divisi</Text>
<View style={[Styles.wrapPaper, Styles.noShadow, { backgroundColor: colors.card, borderWidth: 1, borderColor: colors.icon + '20' }]}>
{loading ?
arrSkeleton.map((item, index) => {
<View style={[Styles.p15, Styles.mb100]}>
{dataDetail?.isActive === false && <SectionCancel title="Divisi nonaktif" />}
{/* Deskripsi */}
<View style={[Styles.wrapPaper, Styles.sectionCard, Styles.noShadow, Styles.mb15,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<View style={[Styles.sectionActionRow, { marginBottom: 12 }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialIcons name="info-outline" size={18} color={colors.dimmed} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Deskripsi</Text>
</View>
{loading
? Array.from({ length: 3 }).map((_, i) => (
<View key={i} style={{ height: 13, borderRadius: 6, marginBottom: 8, backgroundColor: colors.icon + '20', width: i === 2 ? '60%' : '100%' }} />
))
: <Text style={[Styles.textDefault, { color: colors.text, lineHeight: 22 }]}>{dataDetail?.desc}</Text>
}
</View>
{/* Tombol tambah anggota */}
{((entityUser.role !== "user" && entityUser.role !== "coadmin") || isAdminDivision) && dataDetail?.isActive && (
<View style={[Styles.wrapPaper, Styles.sectionCard, Styles.mb15,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }]}>
<Pressable
onPress={() => router.push(`/division/${id}/add-member`)}
style={Styles.sectionActionRow}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '18' }]}>
<MaterialCommunityIcons name="account-plus-outline" size={18} color={colors.icon} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Tambah Anggota</Text>
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
</View>
)}
{/* Daftar anggota */}
<View style={[Styles.wrapPaper, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18', padding: 0, overflow: 'hidden' }]}>
{/* Header */}
<View style={[Styles.sectionActionRow, { padding: 16, borderBottomWidth: 1, borderBottomColor: colors.icon + '14' }]}>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.dimmed + '18' }]}>
<MaterialIcons name="people" size={18} color={colors.dimmed} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.flex1, { color: colors.text }]}>Anggota</Text>
{!loading && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>{dataMember.length} anggota</Text>
)}
</View>
{loading
? Array.from({ length: SKELETON_COUNT }).map((_, i) => (
<View key={i} style={[Styles.rowItemsCenter, Styles.ph15,
{ paddingVertical: 14, gap: 14, borderBottomWidth: i < SKELETON_COUNT - 1 ? 1 : 0, borderBottomColor: colors.icon + '14' }]}>
<View style={[Styles.userProfileExtraSmall, { backgroundColor: colors.icon + '20', borderRadius: 100 }]} />
<View style={{ height: 13, borderRadius: 6, flex: 1, backgroundColor: colors.icon + '20', maxWidth: 140 + (i % 3) * 30 }} />
</View>
))
: dataMember.length === 0
? (
<View style={[Styles.contentItemCenter, { paddingVertical: 40 }]}>
<MaterialIcons name="people-outline" size={34} color={colors.icon + '50'} />
<Text style={[Styles.textMediumNormal, Styles.mt10, { color: colors.dimmed }]}>Belum ada anggota</Text>
</View>
)
: dataMember.map((item, index) => {
const canPress = dataDetail?.isActive && (isAdminDivision || (entityUser.role !== "user" && entityUser.role !== "coadmin"))
return (
<Skeleton key={index} width={100} height={10} widthType="percent" borderRadius={10} />
<Pressable
key={index}
onPress={() => canPress && handleChooseMember(item)}
style={({ pressed }) => [
Styles.rowItemsCenter, Styles.ph15,
{
paddingVertical: 13, gap: 14,
borderBottomWidth: index < dataMember.length - 1 ? 1 : 0,
borderBottomColor: colors.icon + '14',
backgroundColor: pressed && canPress ? colors.icon + '0E' : 'transparent',
},
]}
>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>
{item.name}
</Text>
<Text style={[Styles.textMediumNormal, { color: item.isAdmin ? colors.tabActive : colors.dimmed }]}>
{item.isAdmin ? 'Admin' : 'Anggota'}
</Text>
{canPress && <MaterialCommunityIcons name="chevron-right" size={18} color={colors.icon + '60'} />}
</Pressable>
)
})
:
<Text>{dataDetail?.desc}</Text>
}
</View>
}
</View>
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefault, Styles.mv05]}>{dataMember.length} Anggota</Text>
<View style={[Styles.wrapPaper, Styles.noShadow, { backgroundColor: colors.card, borderWidth: 1, borderColor: colors.icon + '20' }]}>
{
((entityUser.role != "user" && entityUser.role != "coadmin") || isAdminDivision) &&
dataDetail?.isActive && (
<BorderBottomItem
onPress={() => { router.push(`/division/${id}/add-member`) }}
borderType="none"
icon={
<View style={[Styles.iconContent]}>
<Feather name="user-plus" size={25} color={'black'} />
</View>
}
title="Tambah Anggota"
/>
)
}
{
loading ?
arrSkeleton.map((item, index) => {
return (
<SkeletonTwoItem key={index} />
)
})
:
dataMember.map((item, index) => {
return (
<BorderBottomItem
key={index}
borderType="bottom"
onPress={() => { dataDetail?.isActive && (isAdminDivision || (entityUser.role != "user" && entityUser.role != "coadmin")) && handleChooseMember(item) }}
icon={
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="sm" />
}
title={item.name}
rightTopInfo={item.isAdmin ? "Admin" : "Anggota"}
/>
)
})
}
</View>
</View>
</View>
</ScrollView>
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title={dataMemberChoose.name}>
<View>
<Pressable style={[Styles.wrapItemBorderBottom]} onPress={() => { handleMemberAdmin() }}>
<View style={[Styles.rowItemsCenter]}>
<View style={[Styles.iconContent]}>
<MaterialIcons name="verified-user" size={25} color={'black'} />
</View>
<View style={[Styles.rowSpaceBetween, { width: '88%' }]}>
<View style={[Styles.ml10]}>
<Text style={[Styles.textDefault]}>{dataMemberChoose.isAdmin ? 'Memberhentikan sebagai admin' : 'Jadikan admin'}</Text>
</View>
</View>
</View>
</Pressable>
<Pressable style={[Styles.wrapItemBorderBottom]} onPress={() => { handleMemberOut() }}>
<View style={[Styles.rowItemsCenter]}>
<View style={[Styles.iconContent, ColorsStatus.info]}>
<MaterialCommunityIcons name="close-circle" size={25} color={colors.primary} />
</View>
<View style={[Styles.rowSpaceBetween, { width: '88%' }]}>
<View style={[Styles.ml10]}>
<Text style={[Styles.textDefault]}>Keluarkan dari divisi</Text>
</View>
</View>
</View>
</Pressable>
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<MaterialIcons name="verified-user" color={colors.text} size={25} />}
title={dataMemberChoose.isAdmin ? 'Berhentikan admin' : 'Jadikan admin'}
onPress={handleMemberAdmin}
/>
<MenuItemRow
icon={<MaterialCommunityIcons name="account-remove" color={colors.text} size={25} />}
title="Keluarkan"
onPress={handleMemberOut}
/>
</View>
</DrawerBottom>

View File

@@ -117,7 +117,7 @@ export default function CreateDivisionAddAdmin() {
return (
<Pressable
key={index}
style={[Styles.itemSelectModal]}
style={[Styles.itemSelectModal, { borderBottomColor: colors.icon + '20' }]}
onPress={() => {
!found && onChoose(item.idUser)
}}

View File

@@ -121,7 +121,7 @@ export default function CreateDivisionAddMember() {
return (
<Pressable
key={index}
style={[Styles.itemSelectModal]}
style={[Styles.itemSelectModal, { borderBottomColor: colors.icon + '20' }]}
onPress={() => {
!found && onChoose(item.id, item.name, item.img)
}}

View File

@@ -17,6 +17,7 @@ import {
Ionicons,
MaterialCommunityIcons
} from "@expo/vector-icons";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
@@ -37,6 +38,18 @@ export default function ListDivision() {
cat?: string;
}>();
const [isList, setList] = useState(false);
useEffect(() => {
AsyncStorage.getItem('division_view_mode').then((val) => {
if (val !== null) setList(val === 'list')
})
}, [])
function toggleView() {
const next = !isList
setList(next)
AsyncStorage.setItem('division_view_mode', next ? 'list' : 'grid')
}
const entityUser = useSelector((state: any) => state.user)
const { token, decryptToken } = useAuthSession()
const { colors } = useTheme();
@@ -184,11 +197,7 @@ export default function ListDivision() {
<View style={[Styles.rowSpaceBetween, { alignItems: 'center' }]}>
<InputSearch width={68} onChange={setSearch} />
<Pressable
onPress={() => {
setList(!isList);
}}
>
<Pressable onPress={toggleView}>
<MaterialCommunityIcons
name={isList ? "format-list-bulleted" : "view-grid"}
color={colors.text}
@@ -197,7 +206,7 @@ export default function ListDivision() {
</Pressable>
</View>
{(entityUser.role == "supadmin" || entityUser.role == "developer") && (
<View style={[Styles.mv05, Styles.rowOnly]}>
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>

View File

@@ -23,13 +23,12 @@ export default function Feature() {
}}
/>
<View style={[Styles.p15]}>
<View style={[Styles.rowSpaceBetween, Styles.mb15]}>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 20 }}>
<ButtonFiturMenu icon={<Feather name="users" size={30} color={colors.icon} />} text="Divisi" onPress={() => { router.push('/division?active=true') }} />
<ButtonFiturMenu icon={<Feather name="bar-chart" size={30} color={colors.icon} />} text="Kegiatan" onPress={() => { router.push('/project?status=0') }} />
<ButtonFiturMenu icon={<Ionicons name="megaphone-outline" size={30} color={colors.icon} />} text="Pengumuman" onPress={() => { router.push('/announcement') }} />
<ButtonFiturMenu icon={<Ionicons name="chatbubbles-outline" size={30} color={colors.icon} />} text="Diskusi" onPress={() => { router.push('/discussion?active=true') }} />
</View>
<View style={[Styles.rowSpaceBetween, Styles.mb15, (entityUser.role == 'cosupadmin' ? Styles.w70 : entityUser.role == 'supadmin' || entityUser.role == 'developer' ? Styles.w100 : Styles.w40)]}>
<ButtonFiturMenu icon={<Feather name="calendar" size={30} color={colors.icon} />} text="Kalender" onPress={() => { router.push('/village-calendar') }} />
<ButtonFiturMenu icon={<MaterialCommunityIcons name="account-group-outline" size={30} color={colors.icon} />} text="Anggota" onPress={() => { router.push('/member') }} />
<ButtonFiturMenu icon={<MaterialCommunityIcons name="account-tie-outline" size={30} color={colors.icon} />} text="Jabatan" onPress={() => { router.push('/position') }} />
{
@@ -39,7 +38,6 @@ export default function Feature() {
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<>
<ButtonFiturMenu icon={<Ionicons name="bookmarks-outline" size={30} color={colors.icon} />} text="Lembaga Desa" onPress={() => { router.push('/group') }} />
{/* <ButtonFiturMenu icon={<Ionicons name="color-palette-sharp" size={30} color={colors.icon} />} text="Tema" onPress={() => { }} /> */}
<ButtonFiturMenu icon={<Ionicons name="images-outline" size={30} color={colors.icon} />} text="Banner" onPress={() => { router.push('/banner') }} />
</>
}

View File

@@ -1,3 +1,4 @@
import GuideOverlay from "@/components/GuideOverlay";
import ModalConfirmation from "@/components/ModalConfirmation";
import BorderBottomItem from "@/components/borderBottomItem";
import { ButtonForm } from "@/components/buttonForm";
@@ -12,6 +13,8 @@ import WrapTab from "@/components/wrapTab";
import Styles from "@/constants/Styles";
import { apiDeleteGroup, apiEditGroup, apiGetGroup } from "@/lib/api";
import { setUpdateGroup } from "@/lib/groupSlice";
import { GUIDE_GROUP } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
@@ -41,6 +44,7 @@ export default function Index() {
const [titleChoose, setTitleChoose] = useState('')
const queryClient = useQueryClient()
const [refreshing, setRefreshing] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('group')
const dispatch = useDispatch()
const update = useSelector((state: any) => state.groupUpdate)
@@ -136,6 +140,7 @@ export default function Index() {
return (
<View style={[Styles.p15, { flex: 1, backgroundColor: colors.background }]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_GROUP} onDismiss={dismissGuide} />
<View style={[Styles.mb10]}>
<WrapTab>
<ButtonTab

View File

@@ -1,6 +1,5 @@
import AppHeader from "@/components/AppHeader";
import ImageUser from "@/components/imageNew";
import ItemDetailMember from "@/components/itemDetailMember";
import LabelStatus from "@/components/labelStatus";
import HeaderRightMemberDetail from "@/components/member/headerMemberDetail";
import Skeleton from "@/components/skeleton";
@@ -11,6 +10,7 @@ import { valueRoleUser } from "@/constants/RoleUser";
import Styles from "@/constants/Styles";
import { apiGetProfile } from "@/lib/api";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { router, Stack, useLocalSearchParams } from "expo-router";
import React, { useEffect, useState } from "react";
@@ -30,6 +30,7 @@ type Props = {
group: string,
img: string,
isActive: boolean,
isApprover: boolean,
role: string
}
@@ -89,7 +90,7 @@ export default function MemberDetail() {
showBack={true}
onPressLeft={() => router.back()}
right={
(entityUser.role != "user") && isEdit ? <HeaderRightMemberDetail active={data?.isActive} id={id} /> : <></>
(entityUser.role != "user") && isEdit ? <HeaderRightMemberDetail active={data?.isActive} id={id} isApprover={data?.isApprover ?? false} /> : <></>
}
/>
)
@@ -109,51 +110,71 @@ export default function MemberDetail() {
colors={[colors.header, colors.homeGradient]}
style={[Styles.wrapHeadViewMember]}
>
{
loading ?
<>
<Skeleton width={100} height={100} borderRadius={100} />
<Skeleton width={200} height={10} borderRadius={5} />
<Skeleton width={150} height={10} borderRadius={5} />
</>
:
<>
<Pressable onPress={() => setPreview(true)}>
{loading ? (
<>
<Skeleton width={100} height={100} borderRadius={100} />
<Skeleton width={200} height={10} borderRadius={5} />
<Skeleton width={150} height={10} borderRadius={5} />
</>
) : (
<>
<Pressable onPress={() => setPreview(true)}>
<View style={[Styles.memberAvatarRing]}>
<ImageUser src={`${ConstEnv.url_storage}/files/${data?.img}`} size="lg" onError={setErrorImg} />
</Pressable>
<Text style={[Styles.textSubtitle, Styles.cWhite, Styles.mt10, Styles.textCenter]}>{data?.name}</Text>
<Text style={[Styles.textMediumNormal, Styles.cWhite]}>{data?.role}</Text>
</>
}
</View>
</Pressable>
<Text style={[Styles.textSubtitle, Styles.cWhite, Styles.mt10, Styles.textCenter]}>{data?.name}</Text>
<Text style={[Styles.textMediumNormal, Styles.cWhiteDimmed]}>{data?.role}</Text>
<View style={[Styles.memberBadgeRow]}>
{data?.isApprover && (
<View style={[Styles.memberBadgeApprover]}>
<Text style={[Styles.textSmallSemiBold, Styles.cWhite]}>APPROVER</Text>
</View>
)}
<View style={[Styles.memberBadgePill, { backgroundColor: data?.isActive ? colors.success : colors.error }]}>
<Text style={[Styles.textSmallSemiBold, Styles.cWhite]}>{data?.isActive ? 'AKTIF' : 'TIDAK AKTIF'}</Text>
</View>
</View>
</>
)}
</LinearGradient>
<View style={[Styles.p15]}>
<View style={[Styles.rowSpaceBetween]}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Informasi</Text>
<LabelStatus
size="small"
category={data?.isActive ? 'success' : 'error'}
text={data?.isActive ? 'AKTIF' : 'TIDAK AKTIF'}
/>
</View>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<Skeleton key={index} width={100} widthType="percent" height={25} borderRadius={5} />
)
})
:
<>
<ItemDetailMember category="nik" value={data?.nik} />
<ItemDetailMember category="group" value={data?.group} />
<ItemDetailMember category="position" value={data?.position} />
<ItemDetailMember category="phone" value={`+62${data?.phone}`} />
<ItemDetailMember category="email" value={data?.email} />
<ItemDetailMember category="gender" value={data?.gender == "F" ? "Perempuan" : "Laki-Laki"} />
</>
}
<View style={[Styles.p15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mb08, { color: colors.dimmed }]}>Informasi</Text>
<View>
{loading ? (
arrSkeleton.map((_, index) => (
<View key={index} style={[Styles.pv14, { borderBottomWidth: index < arrSkeleton.length - 1 ? 1 : 0, borderBottomColor: `${colors.dimmed}30` }]}>
<Skeleton width={80} height={8} borderRadius={4} />
<View style={[Styles.mt05]}>
<Skeleton width={60} widthType="percent" height={10} borderRadius={4} />
</View>
</View>
))
) : (
[
{ icon: <MaterialCommunityIcons name="card-account-details" size={20} color={colors.icon} />, label: 'NIK', value: data?.nik },
{ icon: <MaterialCommunityIcons name="office-building-outline" size={20} color={colors.icon} />, label: 'Lembaga Desa', value: data?.group },
{ icon: <MaterialCommunityIcons name="account-tie" size={20} color={colors.icon} />, label: 'Jabatan', value: data?.position },
{ icon: <MaterialIcons name="phone" size={20} color={colors.icon} />, label: 'No Telepon', value: `+62${data?.phone}` },
{ icon: <MaterialIcons name="email" size={20} color={colors.icon} />, label: 'Email', value: data?.email },
{ icon: <MaterialCommunityIcons name="gender-male-female" size={20} color={colors.icon} />, label: 'Jenis Kelamin', value: data?.gender == "F" ? "Perempuan" : "Laki-Laki" },
].map((item, index, arr) => (
<View
key={index}
style={[Styles.memberInfoRow, { borderBottomWidth: index < arr.length - 1 ? 1 : 0, borderBottomColor: `${colors.dimmed}30` }]}
>
<View style={[Styles.memberInfoIcon]}>
{item.icon}
</View>
<View style={[Styles.memberInfoContent]}>
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>{item.label}</Text>
<Text style={[Styles.textDefault, Styles.mt02, { color: colors.text }]} numberOfLines={1}>{item.value ?? '-'}</Text>
</View>
</View>
))
)}
</View>
</View>
</ScrollView>

View File

@@ -171,11 +171,9 @@ export default function EditMember() {
}
function checkForm() {
if (Object.values(error).some((v) => v == true) || Object.values(data).some((v) => v == "")) {
setDisableBtn(true)
} else {
setDisableBtn(false)
}
const requiredFields: (keyof Props)[] = ["idPosition", "idUserRole", "nik", "name", "email", "phone", "gender"];
const hasEmpty = requiredFields.some((key) => data[key] === "");
setDisableBtn(Object.values(error).some((v) => v === true) || hasEmpty);
}
useEffect(() => {

View File

@@ -1,3 +1,4 @@
import GuideOverlay from "@/components/GuideOverlay";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonTab from "@/components/buttonTab";
import ImageUser from "@/components/imageNew";
@@ -9,6 +10,8 @@ import WrapTab from "@/components/wrapTab";
import { ConstEnv } from "@/constants/ConstEnv";
import Styles from "@/constants/Styles";
import { apiGetUser } from "@/lib/api";
import { GUIDE_MEMBER } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather } from "@expo/vector-icons";
@@ -42,6 +45,7 @@ export default function Index() {
const queryClient = useQueryClient()
const [status, setStatus] = useState<'true' | 'false'>(active == 'false' ? 'false' : 'true')
const [refreshing, setRefreshing] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('member')
// TanStack Query for Members with Infinite Scroll
const {
@@ -117,6 +121,7 @@ export default function Index() {
return (
<View style={[Styles.p15, { flex: 1, backgroundColor: colors.background }]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_MEMBER} onDismiss={dismissGuide} />
<View>
<WrapTab>
<ButtonTab
@@ -137,7 +142,7 @@ export default function Index() {
<InputSearch onChange={setSearch} />
{
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<View style={[Styles.mv05, Styles.rowOnly]}>
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>

View File

@@ -1,17 +1,18 @@
import BorderBottomItemVertical from "@/components/borderBottomItemVertical";
import AppHeader from "@/components/AppHeader";
import ModalConfirmation from "@/components/ModalConfirmation";
import SkeletonTwoItem from "@/components/skeletonTwoItem";
import Text from "@/components/Text";
import { ColorsStatus } from "@/constants/ColorsStatus";
import Styles from "@/constants/Styles";
import { apiGetNotification, apiReadOneNotification } from "@/lib/api";
import { apiGetNotification, apiReadAllNotification, apiReadOneNotification } from "@/lib/api";
import { setUpdateNotification } from "@/lib/notificationSlice";
import { pushToPage } from "@/lib/pushToPage";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather } from "@expo/vector-icons";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { router, Stack } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { RefreshControl, SafeAreaView, View, VirtualizedList } from "react-native";
import { FlatList, Pressable, RefreshControl, SafeAreaView, View } from "react-native";
import { useDispatch, useSelector } from "react-redux";
type Props = {
@@ -24,6 +25,22 @@ type Props = {
createdAt: string
}
type HeaderRow = { _type: 'header'; date: string }
type ItemRow = Props & { _type: 'item' }
type ListRow = HeaderRow | ItemRow
function getNotifStyle(category: string): { icon: keyof typeof Feather.glyphMap; color: string } {
if (category === 'announcement') return { icon: 'volume-2', color: '#3B82F6' }
if (category === 'project') return { icon: 'activity', color: '#10B981' }
if (category.includes('/task')) return { icon: 'clipboard', color: '#8B5CF6' }
if (category === 'division') return { icon: 'users', color: '#3B82F6' }
if (category.includes('/discussion') || category === 'discussion-general') return { icon: 'message-square', color: '#06B6D4' }
if (category.includes('/calendar')) return { icon: 'calendar', color: '#F59E0B' }
if (category.includes('/document')) return { icon: 'file-text', color: '#FBBF24' }
if (category === 'member') return { icon: 'user', color: '#1F3C88' }
return { icon: 'bell', color: '#6B7280' }
}
export default function Notification() {
const { token, decryptToken } = useAuthSession()
const { colors } = useTheme();
@@ -31,8 +48,9 @@ export default function Notification() {
const dispatch = useDispatch()
const updateNotification = useSelector((state: any) => state.notificationUpdate)
const [refreshing, setRefreshing] = useState(false)
const [markingAll, setMarkingAll] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
// TanStack Query for Notifications with Infinite Scroll
const {
data,
fetchNextPage,
@@ -55,12 +73,42 @@ export default function Notification() {
staleTime: 0,
})
// Flatten pages into a single data array
const flatData = useMemo(() => {
return data?.pages.flatMap(page => page.data) || [];
}, [data])
// Refetch when manual update state changes
const listData = useMemo<ListRow[]>(() => {
const BULAN: Record<string, number> = {
'JAN': 0, 'FEB': 1, 'MAR': 2, 'APR': 3, 'MEI': 4, 'JUN': 5,
'JUL': 6, 'AGU': 7, 'SEP': 8, 'OKT': 9, 'NOV': 10, 'DES': 11,
}
const parseDate = (str: string) => {
const [d, m, y] = str.split(' ')
return new Date(Number(y), BULAN[m] ?? 0, Number(d)).getTime()
}
const groups: Record<string, Props[]> = {}
const dateOrder: string[] = []
flatData.forEach((item) => {
if (!groups[item.createdAt]) {
groups[item.createdAt] = []
dateOrder.push(item.createdAt)
}
groups[item.createdAt].push(item)
})
dateOrder.sort((a, b) => parseDate(b) - parseDate(a))
const result: ListRow[] = []
dateOrder.forEach((date) => {
result.push({ _type: 'header', date })
const sorted = [...groups[date]].sort((a, b) => Number(a.isRead) - Number(b.isRead))
sorted.forEach((item) => result.push({ ...item, _type: 'item' }))
})
return result
}, [flatData])
useEffect(() => {
refetch()
}, [updateNotification, refetch])
@@ -71,16 +119,26 @@ export default function Notification() {
setRefreshing(false)
};
const loadMoreData = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
const hasUnread = flatData.some((item) => !item.isRead)
async function handleReadAll() {
try {
setMarkingAll(true)
const hasil = await decryptToken(String(token?.current))
await apiReadAllNotification({ user: hasil })
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
dispatch(setUpdateNotification(!updateNotification))
} catch (error) {
console.error(error)
} finally {
setMarkingAll(false)
}
};
}
async function handleReadNotification(id: string, category: string, idContent: string) {
try {
const hasil = await decryptToken(String(token?.current))
const response = await apiReadOneNotification({ user: hasil, id: id })
await apiReadOneNotification({ user: hasil, id: id })
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
pushToPage(category, idContent)
dispatch(setUpdateNotification(!updateNotification))
@@ -89,70 +147,150 @@ export default function Notification() {
}
}
const arrSkeleton = [0, 1, 2, 3, 4]
const getItem = (_data: unknown, index: number): Props => ({
id: flatData[index]?.id,
title: flatData[index]?.title,
desc: flatData[index]?.desc,
category: flatData[index]?.category,
idContent: flatData[index]?.idContent,
isRead: flatData[index]?.isRead,
createdAt: flatData[index]?.createdAt,
});
async function handleMarkOneRead(id: string) {
try {
const hasil = await decryptToken(String(token?.current))
await apiReadOneNotification({ user: hasil, id: id })
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
dispatch(setUpdateNotification(!updateNotification))
} catch (error) {
console.error(error)
}
}
return (
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<View style={[Styles.p15]}>
{
isLoading ?
arrSkeleton.map((item, index) => {
return (
<SkeletonTwoItem key={index} />
)
})
:
flatData.length > 0 ?
<VirtualizedList
data={flatData}
getItemCount={() => flatData.length}
getItem={getItem}
renderItem={({ item, index }: { item: Props, index: number }) => {
return (
<BorderBottomItemVertical
borderType="bottom"
icon={
<View style={[Styles.iconContent, item.isRead && ColorsStatus.secondary]}>
<Feather name="bell" size={25} color="black" />
</View>
}
title={item.title}
rightTopInfo={item.createdAt}
desc={item.desc}
textColor={item.isRead ? 'gray' : colors.text}
onPress={() => {
handleReadNotification(item.id, item.category, item.idContent)
}}
bgColor={'transparent'}
/>
)
}}
keyExtractor={(item, index) => String(index)}
onEndReached={loadMoreData}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
<Stack.Screen
options={{
header: () => (
<AppHeader
title="Notifikasi"
showBack={true}
onPressLeft={() => router.back()}
right={
hasUnread ? (
<Pressable
onPress={() => setShowConfirm(true)}
disabled={markingAll}
style={{ opacity: markingAll ? 0.5 : 1, padding: 4 }}
>
<Feather name="check-square" size={20} color="white" />
</Pressable>
) : undefined
}
/>
)
}}
/>
<ModalConfirmation
visible={showConfirm}
title="Tandai Semua Dibaca"
message="Semua notifikasi akan ditandai sebagai telah dibaca."
confirmText="Tandai"
cancelText="Batal"
onConfirm={() => {
setShowConfirm(false)
handleReadAll()
}}
onCancel={() => setShowConfirm(false)}
/>
<View style={[Styles.flex1, Styles.ph15, Styles.notifContainer]}>
{isLoading ? (
[0, 1, 2, 3, 4].map((_, i) => <SkeletonTwoItem key={i} />)
) : flatData.length === 0 ? (
<View style={[Styles.contentItemCenter, Styles.mt30]}>
<Feather name="bell-off" size={42} color={colors.icon + '40'} />
<Text style={[Styles.mt10, { color: colors.dimmed, fontSize: 14 }]}>
Tidak ada notifikasi
</Text>
</View>
) : (
<FlatList
data={listData}
keyExtractor={(item, index) => String(index)}
showsVerticalScrollIndicator={false}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage()
}}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
:
<Text style={[Styles.textDefault, Styles.textCenter, { color: colors.dimmed }]}>Tidak ada data</Text>
}
}
renderItem={({ item }) => {
if (item._type === 'header') {
return (
<View style={[Styles.rowItemsCenter, Styles.notifHeaderRow]}>
<Text style={[Styles.notifDateText, { color: colors.dimmed }]}>
{item.date}
</Text>
<View style={[Styles.notifDateSeparator, { backgroundColor: colors.icon + '20' }]} />
</View>
)
}
const { icon, color } = getNotifStyle(item.category)
return (
<Pressable
onPress={() => handleReadNotification(item.id, item.category, item.idContent)}
style={({ pressed }) => [Styles.notifItemRow, {
borderColor: colors.icon + '20',
backgroundColor: pressed
? colors.icon + '10'
: item.isRead
? colors.icon + '10'
: colors.card,
}]}
>
<View style={[Styles.notifIconContainer, { backgroundColor: color + '20' }]}>
<Feather name={icon} size={20} color={color} />
</View>
<View style={[Styles.flex1, Styles.notifContent]}>
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter]}>
<View style={[Styles.flex1, Styles.mr10]}>
<Text
style={[Styles.textDefaultSemiBold, { color: item.isRead ? colors.dimmed : colors.text }]}
numberOfLines={1}
>
{item.title}
</Text>
</View>
{!item.isRead && (
<Pressable
onPress={(e) => {
e.stopPropagation()
handleMarkOneRead(item.id)
}}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, flexShrink: 0 })}
>
<Text style={Styles.notifMarkReadText}>
Tandai dibaca
</Text>
</Pressable>
)}
</View>
<Text
style={[Styles.textMediumNormal, { color: item.isRead ? colors.dimmed : colors.text, opacity: item.isRead ? 0.7 : 1 }]}
numberOfLines={2}
>
{item.desc}
</Text>
</View>
</Pressable>
)
}}
/>
)}
</View>
</SafeAreaView>
)
}
}

View File

@@ -1,3 +1,4 @@
import GuideOverlay from "@/components/GuideOverlay";
import BorderBottomItem from "@/components/borderBottomItem";
import { ButtonForm } from "@/components/buttonForm";
import ButtonTab from "@/components/buttonTab";
@@ -13,6 +14,8 @@ import WrapTab from "@/components/wrapTab";
import Styles from "@/constants/Styles";
import { apiDeletePosition, apiEditPosition, apiGetPosition } from "@/lib/api";
import { setUpdatePosition } from "@/lib/positionSlice";
import { GUIDE_POSITION } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, Feather, MaterialCommunityIcons } from "@expo/vector-icons";
@@ -48,6 +51,7 @@ export default function Index() {
const queryClient = useQueryClient()
const [refreshing, setRefreshing] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('position')
const dispatch = useDispatch()
const update = useSelector((state: any) => state.positionUpdate)
@@ -160,6 +164,7 @@ export default function Index() {
return (
<View style={[Styles.p15, Styles.flex1, { backgroundColor: colors.background }]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_POSITION} onDismiss={dismissGuide} />
<View>
<WrapTab>
<ButtonTab
@@ -180,7 +185,7 @@ export default function Index() {
<InputSearch onChange={setSearch} />
{
(entityUser.role == "supadmin" || entityUser.role == "developer") &&
<View style={[Styles.mv05, Styles.rowOnly]}>
<View style={[Styles.mt10, Styles.rowOnly]}>
<Text>Filter :</Text>
<LabelStatus size="small" category="secondary" text={nameGroup} style={[Styles.mh05]} />
</View>

View File

@@ -1,6 +1,5 @@
import AppHeader from "@/components/AppHeader";
import { ButtonHeader } from "@/components/buttonHeader";
import ItemDetailMember from "@/components/itemDetailMember";
import Text from "@/components/Text";
import { assetUserImage } from "@/constants/AssetsError";
import { ConstEnv } from "@/constants/ConstEnv";
@@ -9,7 +8,7 @@ import { apiGetProfile } from "@/lib/api";
import { setEntities } from "@/lib/entitiesSlice";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather } from "@expo/vector-icons";
import { Feather, MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { router, Stack } from "expo-router";
import { useState } from "react";
@@ -42,6 +41,15 @@ export default function Profile() {
setRefreshing(false)
};
const infoRows = [
{ icon: <MaterialCommunityIcons name="card-account-details" size={20} color={colors.icon} />, label: 'NIK', value: entities.nik },
{ icon: <MaterialCommunityIcons name="office-building-outline" size={20} color={colors.icon} />, label: 'Lembaga Desa', value: entities.group },
{ icon: <MaterialCommunityIcons name="account-tie" size={20} color={colors.icon} />, label: 'Jabatan', value: entities.position },
{ icon: <MaterialIcons name="phone" size={20} color={colors.icon} />, label: 'No Telepon', value: `0${entities.phone}` },
{ icon: <MaterialIcons name="email" size={20} color={colors.icon} />, label: 'Email', value: entities.email },
{ icon: <MaterialCommunityIcons name="gender-male-female" size={20} color={colors.icon} />, label: 'Jenis Kelamin', value: entities.gender == "F" ? 'Perempuan' : 'Laki-laki' },
]
return (
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
@@ -56,9 +64,7 @@ export default function Profile() {
right={
<ButtonHeader
item={<Feather name="settings" size={20} color="white" />}
onPress={() => {
router.push('/setting')
}}
onPress={() => router.push('/setting')}
/>
}
/>
@@ -75,32 +81,47 @@ export default function Profile() {
}
style={[Styles.h100, { backgroundColor: colors.background }]}
>
<View style={[Styles.flexColumn]}>
<LinearGradient
colors={[colors.header, colors.homeGradient]}
style={[Styles.wrapHeadViewMember]}
>
<Pressable onPress={() => setPreview(true)}>
<LinearGradient
colors={[colors.header, colors.homeGradient]}
style={[Styles.wrapHeadViewMember]}
>
<Pressable onPress={() => setPreview(true)}>
<View style={[Styles.memberAvatarRing]}>
<Image
source={error ? require("../../assets/images/user.jpg") : { uri: `${ConstEnv.url_storage}/files/${entities.img}` }}
onError={() => { setError(true) }}
onError={() => setError(true)}
style={[Styles.userProfileBig]}
/>
</Pressable>
<Text style={[Styles.textSubtitle, Styles.cWhite, Styles.mt10]}>{entities.name}</Text>
<Text style={[Styles.textMediumNormal, Styles.cWhite]}>{entities.role}</Text>
</LinearGradient>
<View style={[Styles.p15]}>
<View style={[Styles.rowSpaceBetween]}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Informasi</Text>
</View>
{/* Note: ItemDetailMember might need updates to support dynamic colors if it uses default text colors */}
<ItemDetailMember category="nik" value={entities.nik} />
<ItemDetailMember category="group" value={entities.group} />
<ItemDetailMember category="position" value={entities.position} />
<ItemDetailMember category="phone" value={`0${entities.phone}`} />
<ItemDetailMember category="email" value={entities.email} />
<ItemDetailMember category="gender" value={entities.gender == "F" ? 'Perempuan' : 'Laki-laki'} />
</Pressable>
<Text style={[Styles.textSubtitle, Styles.cWhite, Styles.mt10, Styles.textCenter]}>{entities.name}</Text>
<Text style={[Styles.textMediumNormal, Styles.cWhiteDimmed]}>{entities.role}</Text>
{entities.isApprover && (
<View style={[Styles.memberBadgeRow, { justifyContent: 'center' }]}>
<View style={[Styles.memberBadgeApprover]}>
<Text style={[Styles.textSmallSemiBold, Styles.cWhite]}>APPROVER</Text>
</View>
</View>
)}
</LinearGradient>
<View style={[Styles.p15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mb08, { color: colors.dimmed }]}>Informasi</Text>
<View>
{infoRows.map((item, index, arr) => (
<View
key={index}
style={[Styles.memberInfoRow, { borderBottomWidth: index < arr.length - 1 ? 1 : 0, borderBottomColor: `${colors.dimmed}30` }]}
>
<View style={[Styles.memberInfoIcon]}>
{item.icon}
</View>
<View style={[Styles.memberInfoContent]}>
<Text style={[Styles.textInformation, { color: colors.dimmed }]}>{item.label}</Text>
<Text style={[Styles.textDefault, Styles.mt02, { color: colors.text }]} numberOfLines={1}>{item.value ?? '-'}</Text>
</View>
</View>
))}
</View>
</View>
</ScrollView>
@@ -114,4 +135,4 @@ export default function Profile() {
/>
</SafeAreaView>
)
}
}

View File

@@ -195,6 +195,8 @@ export default function ProjectAddTask() {
day_label: { color: colors.text },
time_label: { color: colors.text },
weekday_label: { color: colors.text },
button_next_image: { tintColor: colors.text },
button_prev_image: { tintColor: colors.text },
}}
/>
</View>
@@ -227,7 +229,7 @@ export default function ProjectAddTask() {
>
<Text style={[dsbButton ? Styles.cGray : Styles.cWhite]}>Detail</Text>
</Pressable> */}
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} />
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} disabled={from == "" || to == ""} />
</View>
<InputForm
label="Judul Tugas"

View File

@@ -1,4 +1,5 @@
import AppHeader from "@/components/AppHeader";
import GuideOverlay from "@/components/GuideOverlay";
import HeaderRightProjectDetail from "@/components/project/headerProjectDetail";
import SectionFile from "@/components/project/sectionFile";
import SectionLink from "@/components/project/sectionLink";
@@ -9,6 +10,8 @@ import SectionCancel from "@/components/sectionCancel";
import SectionProgress from "@/components/sectionProgress";
import Styles from "@/constants/Styles";
import { apiGetProjectOne } from "@/lib/api";
import { GUIDE_PROJECT_DETAIL } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { router, Stack, useLocalSearchParams } from "expo-router";
@@ -37,11 +40,13 @@ export default function DetailProject() {
const { id } = useLocalSearchParams<{ id: string }>();
const [data, setData] = useState<Props>()
const [progress, setProgress] = useState(0)
const [taskStats, setTaskStats] = useState<{ done: number, total: number } | undefined>()
const [loading, setLoading] = useState(true)
const update = useSelector((state: any) => state.projectUpdate)
const [isMember, setIsMember] = useState(false)
const entityUser = useSelector((state: any) => state.user)
const [refreshing, setRefreshing] = useState(false)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('project-detail')
async function handleLoad(cat: 'data' | 'progress') {
try {
@@ -60,6 +65,17 @@ export default function DetailProject() {
}
}
async function handleLoadTaskStats() {
try {
const hasil = await decryptToken(String(token?.current))
const response = await apiGetProjectOne({ user: hasil, cat: 'task', id: id })
const tasks: { status: number }[] = response.data
setTaskStats({ done: tasks.filter(t => t.status === 1).length, total: tasks.length })
} catch (error) {
console.error(error)
}
}
async function checkMember() {
try {
const hasil = await decryptToken(String(token?.current))
@@ -79,6 +95,10 @@ export default function DetailProject() {
handleLoad('progress')
}, [update.progress])
useEffect(() => {
handleLoadTaskStats()
}, [update.task])
useEffect(() => {
checkMember()
}, [])
@@ -88,6 +108,7 @@ export default function DetailProject() {
setRefreshing(true)
await handleLoad('data')
await handleLoad('progress')
await handleLoadTaskStats()
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshing(false)
};
@@ -112,6 +133,7 @@ export default function DetailProject() {
)
}}
/>
<GuideOverlay visible={guideVisible} steps={GUIDE_PROJECT_DETAIL} onDismiss={dismissGuide} />
<ScrollView
style={[Styles.h100, { backgroundColor: colors.background }]}
refreshControl={
@@ -126,9 +148,9 @@ export default function DetailProject() {
{
data?.reason != null && data?.reason != "" && <SectionCancel text={data?.reason} />
}
<SectionProgress text={`Kemajuan Kegiatan ${progress}%`} progress={progress} />
<SectionProgress progress={progress} doneCount={taskStats?.done} totalCount={taskStats?.total} />
<SectionReportProject refreshing={refreshing} />
<SectionTanggalTugasProject status={data?.status} member={isMember} refreshing={refreshing} />
<SectionTanggalTugasProject status={data?.status} member={isMember} refreshing={refreshing} idGroup={data?.idGroup ?? ''} />
<SectionFile status={data?.status} member={isMember} refreshing={refreshing} />
<SectionLink status={data?.status} member={isMember} refreshing={refreshing} />
<SectionMember status={data?.status} refreshing={refreshing} />

View File

@@ -0,0 +1,377 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import { ButtonForm } from "@/components/buttonForm";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import MenuItemRow from "@/components/menuItemRow";
import ModalConfirmation from "@/components/ModalConfirmation";
import ModalLoading from "@/components/modalLoading";
import Skeleton from "@/components/skeleton";
import Text from "@/components/Text";
import { ConstEnv } from "@/constants/ConstEnv";
import Styles from "@/constants/Styles";
import {
apiAddProjectTaskFile,
apiDeleteProjectTaskFile,
apiGetProjectOne,
apiGetProjectTaskFile,
apiLinkProjectTaskFile,
} from "@/lib/api";
import { setUpdateProject } from "@/lib/projectUpdate";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import * as FileSystem from "expo-file-system";
import { startActivityAsync } from "expo-intent-launcher";
import { router, Stack, useLocalSearchParams } from "expo-router";
import * as Sharing from "expo-sharing";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Platform,
SafeAreaView,
ScrollView,
View,
} from "react-native";
import * as mime from "react-native-mime-types";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
type FileItem = {
id: string; // ProjectTaskFile.id
idFile: string; // ProjectFile.id
name: string;
extension: string;
idStorage: string;
};
type ProjectFile = {
id: string;
name: string;
extension: string;
idStorage: string;
};
export default function ProjectTugasFileScreen() {
const { colors } = useTheme();
const { id, taskId, member: memberParam } = useLocalSearchParams<{ id: string; taskId: string; member: string }>();
const { token, decryptToken } = useAuthSession();
const dispatch = useDispatch();
const update = useSelector((state: any) => state.projectUpdate);
const entityUser = useSelector((state: any) => state.user);
const isMember = memberParam === "true";
const canEdit = isMember || (entityUser.role !== "user" && entityUser.role !== "coadmin");
const [data, setData] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadingOpen, setLoadingOpen] = useState(false);
const [loadingUpload, setLoadingUpload] = useState(false);
const [loadingLink, setLoadingLink] = useState(false);
const [selectFile, setSelectFile] = useState<FileItem | null>(null);
const [isMenuModal, setMenuModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [projectFiles, setProjectFiles] = useState<ProjectFile[]>([]);
const [isPickerModal, setPickerModal] = useState(false);
const [loadingProjectFiles, setLoadingProjectFiles] = useState(false);
const [selectedProjectFiles, setSelectedProjectFiles] = useState<string[]>([]);
const arrSkeleton = Array.from({ length: 4 });
async function loadFiles() {
try {
setLoading(true);
const hasil = await decryptToken(String(token?.current));
const response = await apiGetProjectTaskFile({ user: hasil, id: taskId });
setData(response.data ?? []);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}
async function loadProjectFiles() {
try {
setLoadingProjectFiles(true);
const hasil = await decryptToken(String(token?.current));
const response = await apiGetProjectOne({ user: hasil, cat: "file", id });
setProjectFiles(response.data ?? []);
} catch (error) {
console.error(error);
} finally {
setLoadingProjectFiles(false);
}
}
useEffect(() => {
loadFiles();
}, []);
const openFile = () => {
setMenuModal(false);
setLoadingOpen(true);
const remoteUrl = ConstEnv.url_storage + "/files/" + selectFile?.idStorage;
const fileName = selectFile?.name + "." + selectFile?.extension;
const localPath = `${FileSystem.documentDirectory}/${fileName}`;
const mimeType = mime.lookup(fileName);
FileSystem.downloadAsync(remoteUrl, localPath).then(async ({ uri }) => {
const contentURL = await FileSystem.getContentUriAsync(uri);
try {
if (Platform.OS === "android") {
await startActivityAsync("android.intent.action.VIEW", {
data: contentURL,
flags: 1,
type: mimeType as string,
});
} else {
Sharing.shareAsync(localPath);
}
} catch {
Alert.alert("INFO", "Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini");
} finally {
setLoadingOpen(false);
}
});
};
async function handleDelete() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiDeleteProjectTaskFile({ user: hasil }, String(selectFile?.id));
if (response.success) {
Toast.show({ type: "small", text1: "Berhasil menghapus file" });
dispatch(setUpdateProject({ ...update, task: !update.task }));
loadFiles();
} else {
Toast.show({ type: "small", text1: response.message });
}
} catch (error: any) {
const message = error?.response?.data?.message || "Gagal menghapus file";
Toast.show({ type: "small", text1: message });
} finally {
setMenuModal(false);
}
}
async function handleUpload() {
const result = await DocumentPicker.getDocumentAsync({ type: ["*/*"], multiple: true });
if (result.canceled) return;
try {
setLoadingUpload(true);
const hasil = await decryptToken(String(token?.current));
const fd = new FormData();
for (let i = 0; i < result.assets.length; i++) {
fd.append(`file${i}`, {
uri: result.assets[i].uri,
type: "application/octet-stream",
name: result.assets[i].name,
} as any);
}
fd.append("data", JSON.stringify({ user: hasil }));
const response = await apiAddProjectTaskFile({ data: fd, id: taskId });
if (response.success) {
Toast.show({ type: "small", text1: "Berhasil menambahkan file" });
dispatch(setUpdateProject({ ...update, task: !update.task }));
loadFiles();
} else {
Toast.show({ type: "small", text1: response.message });
}
} catch (error: any) {
const message = error?.response?.data?.message || "Gagal menambahkan file";
Toast.show({ type: "small", text1: message });
} finally {
setLoadingUpload(false);
}
}
function toggleProjectFileSelect(fileId: string) {
setSelectedProjectFiles((prev) =>
prev.includes(fileId) ? prev.filter((v) => v !== fileId) : [...prev, fileId]
);
}
async function handleLinkFiles() {
if (selectedProjectFiles.length === 0) return;
try {
setLoadingLink(true);
const hasil = await decryptToken(String(token?.current));
for (const idFile of selectedProjectFiles) {
await apiLinkProjectTaskFile({ user: hasil, idFile, id: taskId });
}
Toast.show({ type: "small", text1: "Berhasil menambahkan file" });
dispatch(setUpdateProject({ ...update, task: !update.task }));
setPickerModal(false);
setSelectedProjectFiles([]);
loadFiles();
} catch (error: any) {
const message = error?.response?.data?.message || "Gagal menambahkan file";
Toast.show({ type: "small", text1: message });
} finally {
setLoadingLink(false);
}
}
const attachedFileIds = new Set(data.map((f) => f.idFile));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<Stack.Screen
options={{
header: () => (
<AppHeader
title="File Tugas"
showBack={true}
onPressLeft={() => router.back()}
/>
),
}}
/>
<ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} />
<ScrollView>
<View style={[Styles.p15, Styles.mb100]}>
{canEdit && (
<>
<ButtonSelect
value="Upload dari Perangkat"
onPress={handleUpload}
disabled={loadingUpload}
/>
<ButtonSelect
value="Pilih dari File Kegiatan ini"
onPress={() => {
setSelectedProjectFiles([]);
setPickerModal(true);
loadProjectFiles();
}}
/>
</>
)}
{loadingUpload && <ActivityIndicator size="small" style={Styles.mv05} />}
<View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>File Terlampir</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{loading ? (
arrSkeleton.map((_, index) => (
<Skeleton key={index} width={100} height={40} widthType="percent" borderRadius={10} />
))
) : data.length > 0 ? (
data.map((item, index) => (
<BorderBottomItem
key={index}
borderType="all"
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + "." + item.extension}
titleWeight="normal"
onPress={() => {
setSelectFile(item);
setMenuModal(true);
}}
/>
))
) : (
<Text style={[Styles.textDefault, { textAlign: "center", color: colors.dimmed }]}>
Tidak ada file
</Text>
)}
</View>
</View>
</View>
</ScrollView>
{/* Menu per file */}
<DrawerBottom animation="slide" isVisible={isMenuModal} setVisible={setMenuModal} title="Menu">
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<MaterialCommunityIcons name="file-eye" color={colors.text} size={25} />}
title="Lihat / Share"
onPress={openFile}
/>
{canEdit && (
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => {
setMenuModal(false);
setTimeout(() => setShowDeleteModal(true), 600);
}}
/>
)}
</View>
</DrawerBottom>
<ModalConfirmation
visible={showDeleteModal}
title="Konfirmasi"
message="Apakah Anda yakin ingin menghapus file ini?"
onConfirm={() => {
setShowDeleteModal(false);
handleDelete();
}}
onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus"
cancelText="Batal"
/>
{/* Picker file dari proyek */}
<DrawerBottom
animation="slide"
isVisible={isPickerModal}
setVisible={setPickerModal}
title="Pilih File Proyek"
height={60}
>
<ScrollView>
{loadingProjectFiles ? (
<ActivityIndicator size="small" />
) : projectFiles.length > 0 ? (
projectFiles.map((item, index) => {
const isAttached = attachedFileIds.has(item.id);
const isSelected = selectedProjectFiles.includes(item.id);
return (
<View key={index} style={isAttached ? { opacity: 0.4 } : undefined}>
<BorderBottomItem
borderType="bottom"
icon={
isAttached || isSelected ? (
<Ionicons name="checkmark-circle" size={25} color={colors.primary} />
) : (
<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />
)
}
title={item.name + "." + item.extension}
titleWeight="normal"
onPress={() => !isAttached && toggleProjectFileSelect(item.id)}
bgColor="transparent"
/>
</View>
);
})
) : (
<Text style={[Styles.textDefault, { textAlign: "center", color: colors.dimmed }]}>
Tidak ada file tersedia
</Text>
)}
</ScrollView>
{projectFiles.length > 0 && (
<View>
<ButtonForm
text={loadingLink ? "Menyimpan..." : `Tambahkan (${selectedProjectFiles.length})`}
disabled={selectedProjectFiles.length === 0 || loadingLink}
onPress={handleLinkFiles} />
</View>
)}
</DrawerBottom>
</SafeAreaView>
);
}

View File

@@ -1,7 +1,5 @@
import AppHeader from "@/components/AppHeader";
import BorderBottomItem from "@/components/borderBottomItem";
import ButtonSaveHeader from "@/components/buttonSaveHeader";
import ButtonSelect from "@/components/buttonSelect";
import DrawerBottom from "@/components/drawerBottom";
import ImageUser from "@/components/imageNew";
import { InputForm } from "@/components/inputForm";
@@ -25,6 +23,7 @@ import * as DocumentPicker from "expo-document-picker";
import { router, Stack } from "expo-router";
import { useEffect, useState } from "react";
import {
Pressable,
SafeAreaView,
ScrollView,
View
@@ -32,6 +31,26 @@ import {
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
function getFileIcon(ext: string): keyof typeof MaterialCommunityIcons.glyphMap {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(ext: string): string {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function CreateProject() {
const { colors } = useTheme();
const [loading, setLoading] = useState(false)
@@ -241,26 +260,23 @@ export default function CreateProject() {
style={[Styles.h100, { backgroundColor: colors.background }]}
>
<View style={[Styles.p15]}>
{
(entityUser.role == "supadmin" || entityUser.role == "developer")
&&
(
<SelectForm
label="Lembaga Desa"
placeholder="Pilih Lembaga Desa"
value={chooseGroup.label}
required
bg={colors.card}
onPress={() => {
setValChoose(chooseGroup.val);
setValSelect("group");
setSelect(true);
}}
error={error.group}
errorText="Lembaga Desa tidak boleh kosong"
/>
)
}
{(entityUser.role == "supadmin" || entityUser.role == "developer") && (
<SelectForm
label="Lembaga Desa"
placeholder="Pilih Lembaga Desa"
value={chooseGroup.label}
required
bg={colors.card}
onPress={() => {
setValChoose(chooseGroup.val);
setValSelect("group");
setSelect(true);
}}
error={error.group}
errorText="Lembaga Desa tidak boleh kosong"
/>
)}
<InputForm
label="Kegiatan"
type="default"
@@ -270,85 +286,154 @@ export default function CreateProject() {
value={dataForm.title}
error={error.title}
errorText="Nama kegiatan tidak boleh kosong"
onChange={(val) => {
validationForm("title", val);
}}
onChange={(val) => validationForm("title", val)}
/>
<ButtonSelect
value="Tambah Tanggal & Tugas"
onPress={() => {
router.push(`/project/create/task`);
}}
error={error.task}
errorText="Tanggal & Tugas tidak boleh kosong"
/>
<ButtonSelect value="Upload File" onPress={pickDocumentAsync} />
<ButtonSelect
value="Pilih Anggota"
onPress={() => {
if (entityUser.role == "supadmin" || entityUser.role == "developer") {
if (chooseGroup.val != "") {
router.push(`/project/create/member`);
} else {
Toast.show({ type: 'small', text1: "Pilih Lembaga Desa terlebih dahulu", })
}
} else {
router.push(`/project/create/member`);
}
}}
error={error.member}
errorText="Anggota tidak boleh kosong"
/>
<SectionListAddTask />
{
fileForm.length > 0 && (
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>File</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
fileForm.map((item, index) => (
<BorderBottomItem
key={index}
borderType="all"
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name}
titleWeight="normal"
onPress={() => { setIndexDelFile(index); setModal(true) }}
/>
))
}
</View>
</View>
)
}
{entitiesMember.length > 0 && (
<View>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text>Anggota</Text>
<Text>Total {entitiesMember.length} Anggota</Text>
</View>
<View style={[Styles.borderAll, Styles.round05, Styles.p10, { borderColor: colors.icon + '20', backgroundColor: colors.card }]}>
{entitiesMember.map(
(item: { img: any; name: any }, index: any) => {
return (
<BorderBottomItem
key={index}
borderType="bottom"
icon={
<ImageUser
src={`${ConstEnv.url_storage}/files/${item.img}`}
size="sm"
/>
}
title={item.name}
/>
);
}
{/* Tanggal & Tugas */}
<View style={[
Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: error.task ? colors.error + '50' : colors.icon + '18' }
]}>
<Pressable
onPress={() => router.push(`/project/create/task`)}
style={[Styles.sectionActionRow, { marginBottom: taskCreate.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialCommunityIcons name="calendar-check-outline" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Tanggal & Tugas</Text>
{taskCreate.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada tugas ditambahkan</Text>
)}
</View>
</View>
)}
{taskCreate.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{taskCreate.length} tugas</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{taskCreate.length > 0 && <SectionListAddTask showTitle={false} />}
{error.task && (
<Text style={[Styles.textMediumNormal, Styles.mt05, { color: colors.error }]}>
Tanggal & Tugas tidak boleh kosong
</Text>
)}
</View>
{/* File */}
<View style={[
Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' }
]}>
<Pressable
onPress={pickDocumentAsync}
style={[Styles.sectionActionRow, { marginBottom: fileForm.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.icon + '15' }]}>
<MaterialCommunityIcons name="paperclip" size={18} color={colors.dimmed} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>File</Text>
{fileForm.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Opsional ketuk untuk upload</Text>
)}
</View>
{fileForm.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.dimmed + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{fileForm.length} file</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{fileForm.length > 0 && (
<View style={Styles.fileGrid}>
{fileForm.map((item, index) => {
const ext = item.name.split('.').pop()?.toLowerCase() ?? ''
const baseName = item.name.includes('.') ? item.name.split('.').slice(0, -1).join('.') : item.name
const iconName = getFileIcon(ext)
const iconColor = getFileColor(ext)
return (
<Pressable
key={index}
onPress={() => { setIndexDelFile(index); setModal(true) }}
style={[Styles.fileCard, { backgroundColor: 'transparent', borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{baseName}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{ext.toUpperCase()}</Text>
</View>
</Pressable>
)
})}
</View>
)}
</View>
{/* Anggota */}
<View style={[
Styles.wrapPaper, Styles.mb15, Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: error.member ? colors.error + '50' : colors.icon + '18' }
]}>
<Pressable
onPress={() => {
if (entityUser.role == "supadmin" || entityUser.role == "developer") {
if (chooseGroup.val != "") {
router.push(`/project/create/member`);
} else {
Toast.show({ type: 'small', text1: "Pilih Lembaga Desa terlebih dahulu" })
}
} else {
router.push(`/project/create/member`);
}
}}
style={[Styles.sectionActionRow, { marginBottom: entitiesMember.length > 0 ? 12 : 0 }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialCommunityIcons name="account-group-outline" size={18} color={colors.tabActive} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>Anggota</Text>
{entitiesMember.length === 0 && (
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Belum ada anggota dipilih</Text>
)}
</View>
{entitiesMember.length > 0 && (
<View style={[Styles.sectionBadge, { backgroundColor: colors.tabActive + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.tabActive }]}>{entitiesMember.length} orang</Text>
</View>
)}
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.dimmed} />
</Pressable>
{entitiesMember.length > 0 && (
<View style={{ gap: 6 }}>
{entitiesMember.map((item: { img: any; name: any; position?: string }, index: any) => (
<View
key={index}
style={[Styles.listItemCard, { borderColor: colors.icon + '18' }]}
>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<Text style={[Styles.textDefault, Styles.flex1, { color: colors.text }]} numberOfLines={1}>{item.name}</Text>
{item.position && (
<View style={[Styles.positionBadge, { backgroundColor: colors.dimmed + '15' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]} numberOfLines={1}>{item.position}</Text>
</View>
)}
</View>
))}
</View>
)}
{error.member && (
<Text style={[Styles.textMediumNormal, Styles.mt05, { color: colors.error }]}>
Anggota tidak boleh kosong
</Text>
)}
</View>
</View>
</ScrollView>
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu">

View File

@@ -179,6 +179,8 @@ export default function CreateProjectAddTask() {
day_label: { color: colors.text },
time_label: { color: colors.text },
weekday_label: { color: colors.text },
button_next_image: { tintColor: colors.text },
button_prev_image: { tintColor: colors.text },
}}
/>
</View>
@@ -204,14 +206,7 @@ export default function CreateProjectAddTask() {
{
(error.endDate || error.startDate) && <Text style={[Styles.textInformation, Styles.mt05, { color: colors.error }]}>Tanggal tidak boleh kosong</Text>
}
{/* <Pressable
style={[Styles.btnTab, Styles.btnLainnya, dsbButton && Styles.btnDisabled]}
disabled={dsbButton}
onPress={() => { setModalDetail(true) }}
>
<Text style={[dsbButton ? Styles.cGray : Styles.cWhite]}>Detail</Text>
</Pressable> */}
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} />
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} disabled={from == "" || to == ""} />
</View>
<InputForm
label="Judul Tugas"

View File

@@ -23,6 +23,7 @@ import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { Pressable, RefreshControl, ScrollView, View, VirtualizedList } from "react-native";
import { useSelector } from "react-redux";
import AsyncStorage from "@react-native-async-storage/async-storage";
type Props = {
id: string;
@@ -50,6 +51,18 @@ export default function ListProject() {
const [search, setSearch] = useState("")
const [isList, setList] = useState(false)
const update = useSelector((state: any) => state.projectUpdate)
useEffect(() => {
AsyncStorage.getItem('division_view_mode').then((val) => {
if (val !== null) setList(val === 'list')
})
}, [])
function toggleView() {
const next = !isList
setList(next)
AsyncStorage.setItem('division_view_mode', next ? 'list' : 'grid')
}
const queryClient = useQueryClient()
const [refreshing, setRefreshing] = useState(false)
@@ -188,11 +201,7 @@ export default function ListProject() {
<View style={[Styles.rowSpaceBetween, Styles.rowItemsCenter]}>
<InputSearch width={68} onChange={setSearch} />
<Pressable
onPress={() => {
setList(!isList);
}}
>
<Pressable onPress={toggleView}>
<MaterialCommunityIcons
name={isList ? "format-list-bulleted" : "view-grid"}
color={colors.text}

View File

@@ -229,6 +229,8 @@ export default function UpdateProjectTask() {
day_label: { color: colors.text },
time_label: { color: colors.text },
weekday_label: { color: colors.text },
button_next_image: { tintColor: colors.text },
button_prev_image: { tintColor: colors.text },
}}
/>
}
@@ -259,7 +261,7 @@ export default function UpdateProjectTask() {
>
<Text style={[dsbButton ? Styles.cGray : Styles.cWhite]}>Detail</Text>
</Pressable> */}
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} />
<ButtonSelect value="Detail" onPress={() => { setModalDetail(true) }} disabled={from == "" || to == ""} />
</View>
<InputForm
label="Judul Tugas"

View File

@@ -9,13 +9,12 @@ import Styles from "@/constants/Styles";
import { apiGetSearch } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign, MaterialIcons } from "@expo/vector-icons";
import { AntDesign, Feather, MaterialIcons } from "@expo/vector-icons";
import { router, Stack } from "expo-router";
import React, { useState } from "react";
import { RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
import { RefreshControl, SafeAreaView, ScrollView, TouchableOpacity, View } from "react-native";
import Toast from "react-native-toast-message";
// ... types ...
type PropsUser = {
id: string
name: string
@@ -38,6 +37,27 @@ type PropDivisi = {
group: string
}
type FilterType = "all" | "member" | "division" | "project"
function SectionHeader({ label, count, colors }: { label: string; count: number; colors: any }) {
return (
<View style={[Styles.rowItemsCenter, Styles.mb08]}>
<Text style={{ fontSize: 11, fontWeight: '600', color: colors.dimmed, letterSpacing: 0.8, textTransform: 'uppercase' }}>
{label}
</Text>
<View style={{
marginLeft: 6,
backgroundColor: colors.icon + '25',
borderRadius: 10,
paddingHorizontal: 7,
paddingVertical: 1,
}}>
<Text style={{ fontSize: 11, color: colors.dimmed, fontWeight: '600' }}>{count}</Text>
</View>
</View>
)
}
export default function Search() {
const { token, decryptToken } = useAuthSession()
const [dataUser, setDataUser] = useState<PropsUser[]>([])
@@ -45,11 +65,16 @@ export default function Search() {
const [dataProject, setDataProject] = useState<PropProject[]>([])
const [refreshing, setRefreshing] = useState(false)
const [search, setSearch] = useState('')
const [activeFilter, setActiveFilter] = useState<FilterType>("all")
const { colors } = useTheme();
const totalResults = dataUser.length + dataDivisi.length + dataProject.length
const hasSearch = search.length >= 3
async function handleSearch(cari: string) {
try {
setSearch(cari)
setActiveFilter("all")
if (cari.length >= 3) {
const user = await decryptToken(String(token?.current))
const hasil = await apiGetSearch({ text: cari, user: user })
@@ -58,7 +83,7 @@ export default function Search() {
setDataDivisi(hasil.data.division)
setDataProject(hasil.data.project)
} else {
return Toast.show({ type: 'small', text1: hasil.message, })
return Toast.show({ type: 'small', text1: hasil.message })
}
} else {
setDataUser([])
@@ -68,15 +93,10 @@ export default function Search() {
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal melakukan pencarian"
Toast.show({
type: 'small',
text1: message
})
Toast.show({ type: 'small', text1: message })
}
}
const handleRefresh = async () => {
setRefreshing(true)
handleSearch(search)
@@ -84,114 +104,203 @@ export default function Search() {
setRefreshing(false)
};
const filters: { key: FilterType; label: string; count: number }[] = [
{ key: "all", label: "Semua", count: totalResults },
{ key: "member", label: "Anggota", count: dataUser.length },
{ key: "division", label: "Divisi", count: dataDivisi.length },
{ key: "project", label: "Kegiatan", count: dataProject.length },
]
const showUser = activeFilter === "all" || activeFilter === "member"
const showDivision = activeFilter === "all" || activeFilter === "division"
const showProject = activeFilter === "all" || activeFilter === "project"
const activeFilterEmpty =
(activeFilter === "member" && dataUser.length === 0) ||
(activeFilter === "division" && dataDivisi.length === 0) ||
(activeFilter === "project" && dataProject.length === 0)
return (
<>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
headerTitle: 'Pencarian',
headerTitleAlign: 'center',
header: () => (
<AppHeader title="Pencarian" showBack={true} onPressLeft={() => router.back()} />
)
}}
/>
<View style={[Styles.p15]}>
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
headerTitle: 'Pencarian',
headerTitleAlign: 'center',
header: () => (
<AppHeader title="Pencarian" showBack={true} onPressLeft={() => router.back()} />
)
}}
/>
<View style={[Styles.flex1]}>
{/* Search bar */}
<View style={[Styles.ph15, { paddingTop: 15 }]}>
<InputSearch onChange={handleSearch} />
{
dataProject.length + dataDivisi.length + dataUser.length > 0
?
<ScrollView
style={[Styles.h100]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
>
{
dataUser.length > 0 &&
<View style={[Styles.mv05, Styles.p10]}>
<Text>ANGGOTA</Text>
{
dataUser.map((item, index) => (
<BorderBottomItem
key={index}
borderType="bottom"
icon={<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} />}
title={item.name}
subtitle={`${item.group}-${item.position}`}
onPress={() => {
router.push(`/member/${item.id}`)
}}
/>
))
}
</View>
}
{
dataDivisi.length > 0 &&
<View style={[Styles.mv05, Styles.p10]}>
<Text>DIVISI</Text>
{
dataDivisi.map((item, index) => (
<BorderBottomItem
key={index}
borderType="bottom"
icon={
<View style={[Styles.iconContent, ColorsStatus.primary]}>
<MaterialIcons name="group" size={25} color="white" />
</View>
}
title={item.name}
subtitle={item.group}
onPress={() => {
router.push(`/division/${item.id}`)
}}
/>
))
}
</View>
}
{
dataProject.length > 0 &&
<View style={[Styles.mv05, Styles.p10]}>
<Text>KEGIATAN</Text>
{
dataProject.map((item, index) => (
<BorderBottomItem
key={index}
borderType="bottom"
icon={
<View style={[Styles.iconContent, ColorsStatus.primary]}>
<AntDesign name="areachart" size={25} color="white" />
</View>
}
title={item.title}
subtitle={item.group}
onPress={() => {
router.push(`/project/${item.id}`)
}}
/>
))
}
</View>
}
</ScrollView>
:
<View style={[Styles.contentItemCenter, Styles.mt10]}>
<Text style={[Styles.textInformation, { color: colors.icon }]}>Tidak ada data</Text>
</View>
}
</View>
</SafeAreaView>
</>
{/* Filter tabs */}
{hasSearch && totalResults > 0 && (
<View style={{ marginTop: 10, flexShrink: 0 }}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[Styles.ph15, { paddingRight: 5, alignItems: 'center' }]}
>
{filters.map((f) => {
const isActive = activeFilter === f.key
return (
<TouchableOpacity
key={f.key}
onPress={() => setActiveFilter(f.key)}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 20,
marginRight: 8,
borderWidth: 1,
borderColor: isActive ? colors.tabActive : colors.icon + '40',
backgroundColor: isActive ? colors.tabActive + '20' : 'transparent',
}}
>
<Text style={{
fontSize: 13,
color: isActive ? colors.tabActive : colors.dimmed,
fontWeight: isActive ? '600' : 'normal',
}}>
{f.label}
</Text>
{f.count > 0 && (
<View style={{
marginLeft: 5,
backgroundColor: isActive ? colors.tabActive : colors.icon + '30',
borderRadius: 10,
minWidth: 18,
height: 18,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 4,
}}>
<Text style={{ fontSize: 11, color: isActive ? 'white' : colors.dimmed, fontWeight: '600' }}>
{f.count}
</Text>
</View>
)}
</TouchableOpacity>
)
})}
</ScrollView>
</View>
)}
{/* Content */}
<View style={[Styles.flex1]}>
{!hasSearch ? (
<View style={[Styles.contentItemCenter, Styles.mt30]}>
<Feather name="search" size={42} color={colors.icon + '40'} />
<Text style={[Styles.mt10, { color: colors.dimmed, fontSize: 14 }]}>
Ketik minimal 3 karakter untuk mencari
</Text>
</View>
) : totalResults === 0 ? (
<View style={[Styles.contentItemCenter, Styles.mt30]}>
<Feather name="inbox" size={42} color={colors.icon + '40'} />
<Text style={[Styles.mt10, { color: colors.dimmed, fontSize: 14 }]}>
Tidak ada hasil untuk "{search}"
</Text>
</View>
) : (
<ScrollView
style={[Styles.flex1]}
contentContainerStyle={[Styles.ph15, { paddingTop: 14, paddingBottom: 30 }]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
>
{/* Anggota */}
{showUser && dataUser.length > 0 && (
<View style={[Styles.mb15]}>
<SectionHeader label="Anggota" count={dataUser.length} colors={colors} />
{dataUser.map((item, index) => (
<View key={index} style={index < dataUser.length - 1 ? Styles.mb05 : undefined}>
<BorderBottomItem
borderType="all"
icon={<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} />}
title={item.name}
subtitle={`${item.group} · ${item.position}`}
onPress={() => router.push(`/member/${item.id}`)}
colorPress
/>
</View>
))}
</View>
)}
{/* Divisi */}
{showDivision && dataDivisi.length > 0 && (
<View style={[Styles.mb15]}>
<SectionHeader label="Divisi" count={dataDivisi.length} colors={colors} />
{dataDivisi.map((item, index) => (
<View key={index} style={index < dataDivisi.length - 1 ? Styles.mb05 : undefined}>
<BorderBottomItem
borderType="all"
icon={
<View style={[Styles.iconContent, ColorsStatus.primary]}>
<MaterialIcons name="group" size={25} color="white" />
</View>
}
title={item.name}
subtitle={item.group}
onPress={() => router.push(`/division/${item.id}`)}
colorPress
/>
</View>
))}
</View>
)}
{/* Kegiatan */}
{showProject && dataProject.length > 0 && (
<View style={[Styles.mb15]}>
<SectionHeader label="Kegiatan" count={dataProject.length} colors={colors} />
{dataProject.map((item, index) => (
<View key={index} style={index < dataProject.length - 1 ? Styles.mb05 : undefined}>
<BorderBottomItem
borderType="all"
icon={
<View style={[Styles.iconContent, ColorsStatus.primary]}>
<AntDesign name="areachart" size={25} color="white" />
</View>
}
title={item.title}
subtitle={item.group}
onPress={() => router.push(`/project/${item.id}`)}
colorPress
/>
</View>
))}
</View>
)}
{/* Empty state untuk filter aktif */}
{activeFilter !== "all" && activeFilterEmpty && (
<View style={[Styles.contentItemCenter, Styles.mt30]}>
<Feather name="inbox" size={42} color={colors.icon + '40'} />
<Text style={[Styles.mt10, { color: colors.dimmed, fontSize: 14 }]}>
Tidak ada hasil di kategori ini
</Text>
</View>
)}
</ScrollView>
)}
</View>
</View>
</SafeAreaView>
)
}
}

View File

@@ -0,0 +1,304 @@
import AppHeader from "@/components/AppHeader";
import ItemDateCalendar from "@/components/calendar/itemDateCalendar";
import GuideOverlay from "@/components/GuideOverlay";
import Skeleton from "@/components/skeleton";
import Text from "@/components/Text";
import Styles from "@/constants/Styles";
import { apiGetVillageCalendarByDate, apiGetVillageCalendarIndicator } from "@/lib/api";
import { GUIDE_VILLAGE_CALENDAR } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather } from "@expo/vector-icons";
import { router, Stack } from "expo-router";
import 'intl';
import 'intl/locale-data/jsonp/id';
import moment from "moment";
import "moment/locale/id";
import { useEffect, useState } from "react";
import { Pressable, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
import Datepicker, {
CalendarComponents,
CalendarDay
} from "react-native-ui-datepicker";
moment.locale('id');
type EventItem = {
id: string;
type: 'calendar' | 'task';
title: string;
desc: string;
dateStart: string;
dateEnd: string;
timeStart: string | null;
timeEnd: string | null;
divisionName: string | null;
projectName: string | null;
idDivision: string | null;
idRef: string;
};
const TYPE_COLORS = {
calendar: {
light: { bg: '#A9B5DF', stick: '#7886C7' },
dark: { bg: '#2D2B5E', stick: '#7886C7' },
},
task: {
light: { bg: '#D6E6F2', stick: '#94B4C1' },
dark: { bg: '#1C3347', stick: '#94B4C1' },
},
};
function VillageEventItem({ item, onPress }: { item: EventItem; onPress: () => void }) {
const { activeTheme, colors } = useTheme();
const palette = TYPE_COLORS[item.type][activeTheme === 'dark' ? 'dark' : 'light'];
return (
<Pressable
style={[Styles.itemEvent, { backgroundColor: palette.bg }]}
onPress={onPress}
>
<View style={[Styles.dividerEvent, { backgroundColor: palette.stick }]} />
<View style={[Styles.flex1]}>
{item.timeStart ? (
<Text style={[Styles.textMediumNormal, { color: activeTheme === 'dark' ? '#ffffff' : '#000000' }]}>
{item.timeStart} - {item.timeEnd}
</Text>
) : (
<Text style={[Styles.textMediumNormal, { color: activeTheme === 'dark' ? '#ffffff' : '#000000' }]}>
{moment(item.dateStart).format('D MMM YYYY')} {moment(item.dateEnd).format('D MMM YYYY')}
</Text>
)}
<Text numberOfLines={1} ellipsizeMode="tail" style={[Styles.textDefaultSemiBold, { color: colors.text }]}>
{item.title}
</Text>
<View style={[Styles.rowItemsCenter]}>
<View style={[Styles.villageEventBadge, { backgroundColor: palette.stick }]}>
<Text style={[Styles.textSmallSemiBold, { color: '#ffffff' }]}>
{item.type === 'calendar' ? 'Divisi' : 'Kegiatan'}
</Text>
</View>
<Text numberOfLines={1} ellipsizeMode="tail" style={[Styles.textMediumNormal, { color: activeTheme === 'dark' ? '#ffffff' : '#000000', flex: 1 }]}>
{item.type === 'calendar' ? item.divisionName : item.projectName}
</Text>
</View>
</View>
</Pressable>
);
}
export default function VillageCalendar() {
const { colors, activeTheme } = useTheme();
const { token, decryptToken } = useAuthSession();
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('village-calendar');
const [selected, setSelected] = useState<any>(new Date());
const [data, setData] = useState<EventItem[]>([]);
const [dataIndicator, setDataIndicator] = useState<{ calendar: string[], task: string[] }>({ calendar: [], task: [] });
const [month, setMonth] = useState<number>(new Date().getMonth());
const [loading, setLoading] = useState(true);
const [loadingBtn, setLoadingBtn] = useState(false);
const [refreshing, setRefreshing] = useState(false);
async function handleLoad(showLoading: boolean) {
try {
setLoading(showLoading);
const hasil = await decryptToken(String(token?.current));
const response = await apiGetVillageCalendarByDate({
user: hasil,
date: moment(selected).format("YYYY-MM-DD"),
});
setData(response.data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}
async function handleLoadIndicator() {
try {
setLoadingBtn(true);
const newDate = new Date(selected?.getFullYear(), month, 1);
const hasil = await decryptToken(String(token?.current));
const response = await apiGetVillageCalendarIndicator({
user: hasil,
date: moment(newDate).format("YYYY-MM-DD"),
});
setDataIndicator(response.data ?? { calendar: [], task: [] });
} catch (error) {
console.error(error);
} finally {
setTimeout(() => setLoadingBtn(false), 500);
}
}
useEffect(() => {
handleLoad(true);
}, [selected]);
useEffect(() => {
handleLoadIndicator();
}, [month]);
const handleRefresh = async () => {
setRefreshing(true);
handleLoad(false);
handleLoadIndicator();
await new Promise(resolve => setTimeout(resolve, 1500));
setRefreshing(false);
};
function handlePressEvent(item: EventItem) {
if (item.type === 'calendar') {
router.push(`/division/${item.idDivision}/calendar/${item.id}` as any);
} else {
router.push(`/project/${item.idRef}` as any);
}
}
const calendarEvents = data.filter(e => e.type === 'calendar');
const taskEvents = data.filter(e => e.type === 'task');
const components: CalendarComponents = {
Day: (day: CalendarDay) => {
const today = moment(String(day.date)).format("YYYY-MM-DD");
return (
<ItemDateCalendar
text={day.text}
isSelected={day.isSelected}
isSignCalendar={dataIndicator.calendar.includes(today)}
isSignTask={dataIndicator.task.includes(today)}
onPress={() => setSelected(new Date(today))}
/>
);
},
IconNext: (
<Pressable onPress={() => !loadingBtn && setMonth(month + 1)}>
<Feather name="chevron-right" size={20} color={loadingBtn ? 'gray' : colors.text} />
</Pressable>
),
IconPrev: (
<Pressable onPress={() => !loadingBtn && setMonth(month - 1)}>
<Feather name="chevron-left" size={20} color={loadingBtn ? 'gray' : colors.text} />
</Pressable>
),
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<Stack.Screen
options={{
headerShown: true,
header: () => (
<AppHeader
title="Kalender"
showBack={true}
onPressLeft={() => router.back()}
/>
),
}}
/>
<GuideOverlay
visible={guideVisible}
steps={GUIDE_VILLAGE_CALENDAR}
onDismiss={dismissGuide}
/>
<ScrollView
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
style={[Styles.h100]}
>
<View style={[Styles.p15]}>
{/* Calendar grid */}
<View style={[Styles.wrapPaper, Styles.p10, { backgroundColor: colors.card, borderColor: colors.background }]}>
<Datepicker
components={components}
mode="single"
date={selected}
month={month}
onMonthChange={(m) => setMonth(m)}
styles={{
selected: Styles.selectedDate,
month_label: { color: colors.text },
month_selector_label: { color: colors.text },
year_label: { color: colors.text },
year_selector_label: { color: colors.text },
day_label: { color: colors.text },
time_label: { color: colors.text },
weekday_label: { color: colors.text },
}}
/>
</View>
{/* Legend */}
<View style={[Styles.rowItemsCenter, Styles.villageEventLegendRow]}>
<View style={[Styles.rowItemsCenter, Styles.villageEventLegendItem]}>
<View style={[Styles.villageEventLegendDot, { backgroundColor: '#7886C7' }]} />
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Acara Divisi</Text>
</View>
<View style={[Styles.rowItemsCenter, Styles.villageEventLegendItem]}>
<View style={[Styles.villageEventLegendDot, { backgroundColor: '#94B4C1' }]} />
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Kegiatan</Text>
</View>
</View>
{/* Calendar events */}
<View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mb05]}>Acara Divisi</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{loading ? (
<>
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
</>
) : calendarEvents.length > 0 ? (
calendarEvents.map((item, index) => (
<VillageEventItem
key={index}
item={item}
onPress={() => handlePressEvent(item)}
/>
))
) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>
Tidak ada acara
</Text>
)}
</View>
</View>
{/* Task events */}
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mb05]}>Kegiatan</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{loading ? (
<>
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
<Skeleton width={100} height={70} borderRadius={10} widthType="percent" />
</>
) : taskEvents.length > 0 ? (
taskEvents.map((item, index) => (
<VillageEventItem
key={index}
item={item}
onPress={() => handlePressEvent(item)}
/>
))
) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>
Tidak ada kegiatan
</Text>
)}
</View>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -46,6 +46,7 @@
"expo-modules-core": "^2.5.0",
"expo-notifications": "~0.31.3",
"expo-router": "~5.1.4",
"expo-secure-store": "~14.2.4",
"expo-sharing": "^13.1.5",
"expo-splash-screen": "~0.30.8",
"expo-status-bar": "~2.2.3",
@@ -1103,6 +1104,8 @@
"expo-router": ["expo-router@5.1.11", "", { "dependencies": { "@expo/metro-runtime": "5.0.5", "@expo/schema-utils": "^0.1.0", "@expo/server": "^0.6.3", "@radix-ui/react-slot": "1.2.0", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "client-only": "^0.0.1", "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "shallowequal": "^1.1.0" }, "peerDependencies": { "@react-navigation/drawer": "^7.3.9", "expo": "*", "expo-constants": "*", "expo-linking": "*", "react-native-reanimated": "*", "react-native-safe-area-context": "*", "react-native-screens": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@react-navigation/drawer", "react-native-reanimated", "react-server-dom-webpack"] }, "sha512-6YQGqQM2rviVSiU6++hrJDPMByHZ7Oiux4XmgoSaGdaHku5QOn9911f2puEUZh2H9ALKBipw5v3ZkrECBd6Zbw=="],
"expo-secure-store": ["expo-secure-store@14.2.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-ePaz4fnTitJJZjAiybaVYGfLWWyaEtepZC+vs9ZBMhQMfG5HUotIcVsDaSo3FnwpHmgwsLVPY2qFeryI6AtULw=="],
"expo-sharing": ["expo-sharing@13.1.5", "", { "peerDependencies": { "expo": "*" } }, "sha512-X/5sAEiWXL2kdoGE3NO5KmbfcmaCWuWVZXHu8OQef7Yig4ZgHFkGD11HKJ5KqDrDg+SRZe4ISd6MxE7vGUgm4w=="],
"expo-splash-screen": ["expo-splash-screen@0.30.10", "", { "dependencies": { "@expo/prebuild-config": "^9.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-Tt9va/sLENQDQYeOQ6cdLdGvTZ644KR3YG9aRlnpcs2/beYjOX1LHT510EGzVN9ljUTg+1ebEo5GGt2arYtPjw=="],

159
components/GuideOverlay.tsx Normal file
View File

@@ -0,0 +1,159 @@
import Text from '@/components/Text';
import Styles from '@/constants/Styles';
import { useTheme } from '@/providers/ThemeProvider';
import { Feather } from '@expo/vector-icons';
import React, { useEffect, useRef, useState } from 'react';
import {
Animated,
Dimensions,
Modal,
TouchableOpacity,
View,
} from 'react-native';
export type GuideStep = {
title: string;
description: string;
// posisi card: pixel absolut atau rasio layar (0.0-1.0), rasio lebih aman untuk layout dinamis
cardTop?: number;
cardTopRatio?: number;
// arrow menunjuk ke atas (elemen ada di atas card) atau ke bawah (elemen ada di bawah card)
arrowDirection?: 'up' | 'down' | 'none';
// offset horizontal arrow dari kiri card (0.0-1.0), default 0.5 = tengah
arrowOffset?: number;
};
type Props = {
visible: boolean;
steps: GuideStep[];
onDismiss: () => void;
};
const { height: SCREEN_H } = Dimensions.get('window');
const CARD_MARGIN = 24;
export default function GuideOverlay({ visible, steps, onDismiss }: Props) {
const { colors } = useTheme();
const [currentStep, setCurrentStep] = useState(0);
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
const step = steps[currentStep];
const isLast = currentStep === steps.length - 1;
const arrowDirection = step?.arrowDirection ?? 'none';
const arrowOffset = step?.arrowOffset ?? 0.5;
const cardTop = step?.cardTopRatio != null
? SCREEN_H * step.cardTopRatio
: (step?.cardTop ?? SCREEN_H * 0.35);
const cardPositionStyle = { top: cardTop };
useEffect(() => {
if (visible) {
setCurrentStep(0);
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true }),
Animated.timing(slideAnim, { toValue: 0, duration: 300, useNativeDriver: true }),
]).start();
} else {
fadeAnim.setValue(0);
slideAnim.setValue(20);
}
}, [visible]);
function animateStep(next: number) {
Animated.sequence([
Animated.timing(slideAnim, { toValue: 12, duration: 120, useNativeDriver: true }),
Animated.timing(slideAnim, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
setCurrentStep(next);
}
if (!visible) return null;
return (
<Modal transparent visible={visible} animationType="none" onRequestClose={onDismiss}>
<Animated.View style={[Styles.guideOverlay, { opacity: fadeAnim }]}>
<Animated.View
style={[
Styles.guideCard,
{ backgroundColor: colors.modalBackground, transform: [{ translateY: slideAnim }] },
cardPositionStyle,
]}
>
{/* Arrow atas — menunjuk ke elemen di atas card */}
{arrowDirection === 'up' && (
<View style={[Styles.guideArrowUp, {
left: `${arrowOffset * 100}%` as any,
borderBottomColor: colors.modalBackground,
}]} />
)}
{/* Header */}
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.mb10]}>
<View style={[Styles.guideBadge, { backgroundColor: colors.icon + '20' }]}>
<Text style={[Styles.textSmallSemiBold, { color: colors.icon }]}>
{currentStep + 1} / {steps.length}
</Text>
</View>
<TouchableOpacity onPress={onDismiss} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Feather name="x" size={18} color={colors.dimmed} />
</TouchableOpacity>
</View>
{/* Content */}
<Text style={[Styles.textDefaultSemiBold, Styles.mb05, { color: colors.text }]}>
{step.title}
</Text>
<Text style={[Styles.textMediumNormal, { color: colors.text, lineHeight: 20 }]}>
{step.description}
</Text>
{/* Dot indicator */}
<View style={[Styles.rowItemsCenter, Styles.guideDotRow]}>
{steps.map((_, i) => (
<View
key={i}
style={[Styles.guideDot, {
backgroundColor: i === currentStep ? colors.icon : colors.icon + '30',
}]}
/>
))}
</View>
{/* Footer */}
<View style={[Styles.rowItemsCenter, Styles.rowSpaceBetween, Styles.mt10]}>
{currentStep > 0 ? (
<TouchableOpacity onPress={() => animateStep(currentStep - 1)} style={Styles.guideButtonSecondary}>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Kembali</Text>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={onDismiss}>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed }]}>Lewati</Text>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => isLast ? onDismiss() : animateStep(currentStep + 1)}
style={[Styles.guideButtonPrimary, { backgroundColor: colors.icon }]}
>
<Text style={[Styles.textMediumSemiBold, { color: '#ffffff' }]}>
{isLast ? 'Selesai' : 'Lanjut'}
</Text>
{!isLast && <Feather name="arrow-right" size={14} color="#ffffff" style={{ marginLeft: 4 }} />}
</TouchableOpacity>
</View>
{/* Arrow bawah — menunjuk ke elemen di bawah card */}
{arrowDirection === 'down' && (
<View style={[Styles.guideArrowDown, {
left: `${arrowOffset * 100}%` as any,
borderTopColor: colors.modalBackground,
}]} />
)}
</Animated.View>
</Animated.View>
</Modal>
);
}

View File

@@ -0,0 +1,123 @@
import Styles from "@/constants/Styles"
import { useTheme } from "@/providers/ThemeProvider"
import { MaterialCommunityIcons } from "@expo/vector-icons"
import { useRef, useState } from "react"
import { ScrollView, View } from "react-native"
import DrawerBottom from "./drawerBottom"
import Skeleton from "./skeleton"
import Text from "./Text"
type ApprovalRecord = {
id: string
status: number // 0=pending, 1=approved, 2=rejected
note?: string
submitter: { name: string }
approver?: { name: string }
createdAt: string
}
type Props = {
isVisible: boolean
setVisible: (value: boolean) => void
data: ApprovalRecord[]
loading: boolean
}
function ApprovalStatusBadge({ status }: { status: number }) {
const { colors } = useTheme()
const config =
status === 1
? { label: 'Disetujui', color: colors.success }
: status === 2
? { label: 'Ditolak', color: colors.error }
: { label: 'Menunggu', color: '#FFA94D' }
return (
<View style={[Styles.approvalBadge, { backgroundColor: config.color + '20' }]}>
<Text style={[Styles.textSmallSemiBold, { color: config.color }]}>
{config.label}
</Text>
</View>
)
}
export default function ModalRiwayatApproval({ isVisible, setVisible, data, loading }: Props) {
const { colors } = useTheme()
const arrSkeleton = Array.from({ length: 3 })
const scrollRef = useRef<ScrollView>(null)
const [scrollOffset, setScrollOffset] = useState(0)
return (
<DrawerBottom
isVisible={isVisible}
setVisible={setVisible}
title="Riwayat Persetujuan"
animation="slide"
height={60}
scrollOffset={scrollOffset}
scrollTo={(p) => scrollRef.current?.scrollTo(p)}
>
<ScrollView
ref={scrollRef}
showsVerticalScrollIndicator={false}
onScroll={({ nativeEvent }) => setScrollOffset(nativeEvent.contentOffset.y)}
scrollEventThrottle={16}
>
{loading ? (
arrSkeleton.map((_, i) => (
<View key={i} style={[Styles.mb10]}>
<Skeleton width={100} widthType="percent" height={80} borderRadius={10} />
</View>
))
) : data.length > 0 ? (
data.map((item, index) => (
<View
key={item.id}
style={[Styles.approvalItem, { borderColor: colors.icon + '30' }]}
>
{/* Status + tanggal */}
<View style={[Styles.rowItemsCenter, Styles.approvalItemHeader]}>
<ApprovalStatusBadge status={item.status} />
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>
{item.createdAt}
</Text>
</View>
{/* Pengaju */}
<View style={[Styles.rowItemsCenter, Styles.mb05]}>
<MaterialCommunityIcons name="account-arrow-up-outline" size={15} color={colors.text} style={Styles.approvalIconMr} />
<Text style={[Styles.textMediumSemiBold]}>Diajukan Oleh: </Text>
<Text style={[Styles.textMediumNormal]}>{item.submitter.name}</Text>
</View>
{/* Approver */}
<View style={[Styles.rowItemsCenter, item.note ? Styles.mb05 : {}]}>
<MaterialCommunityIcons name="account-check-outline" size={15} color={colors.text} style={Styles.approvalIconMr} />
<Text style={[Styles.textMediumSemiBold]}>Disetujui Oleh: </Text>
<Text style={[Styles.textMediumNormal]}>
{item.approver?.name ?? '-'}
</Text>
</View>
{/* Catatan penolakan */}
{item.note && (
<View style={[Styles.approvalNoteBox, { backgroundColor: colors.icon + '12' }]}>
<Text style={[Styles.textSmallSemiBold, Styles.approvalNoteLabel, { color: colors.error }]}>
Alasan Penolakan
</Text>
<Text style={[Styles.textMediumNormal]}>
{item.note}
</Text>
</View>
)}
</View>
))
) : (
<Text style={[Styles.textDefault, Styles.approvalEmptyText, { color: colors.dimmed }]}>
Belum ada riwayat persetujuan
</Text>
)}
</ScrollView>
</DrawerBottom>
)
}

View File

@@ -0,0 +1,78 @@
import Styles from "@/constants/Styles"
import { useTheme } from "@/providers/ThemeProvider"
import { useState } from "react"
import { TouchableOpacity, View } from "react-native"
import DrawerBottom from "./drawerBottom"
import { InputForm } from "./inputForm"
import Text from "./Text"
type Props = {
isVisible: boolean
setVisible: (value: boolean) => void
onTolak: (note: string) => void
loading?: boolean
}
export default function ModalTolakApproval({ isVisible, setVisible, onTolak, loading }: Props) {
const { colors } = useTheme()
const [note, setNote] = useState('')
const [error, setError] = useState(false)
function handleClose(value: boolean) {
setNote('')
setError(false)
setVisible(value)
}
function handleSubmit() {
if (!note.trim()) {
setError(true)
return
}
onTolak(note.trim())
setNote('')
setError(false)
}
return (
<DrawerBottom
isVisible={isVisible}
setVisible={handleClose}
title="Tolak Tugas"
animation="slide"
height={45}
keyboard
>
<View style={{ flex: 1 }}>
<InputForm
label="Alasan Penolakan"
placeholder="Tuliskan alasan penolakan..."
type="default"
multiline
bg="transparent"
value={note}
onChange={setNote}
error={error}
errorText="Alasan penolakan wajib diisi"
required
/>
</View>
<TouchableOpacity
onPress={handleSubmit}
disabled={loading}
style={{
backgroundColor: loading ? colors.error + '60' : colors.error,
borderRadius: 30,
paddingVertical: 10,
alignItems: 'center',
marginTop: 8,
}}
>
<Text style={[Styles.textDefaultSemiBold, Styles.cWhite]}>
{loading ? 'Memproses...' : 'Tolak Tugas'}
</Text>
</TouchableOpacity>
</DrawerBottom>
)
}

View File

@@ -14,11 +14,11 @@ export function ButtonFiturMenu({ onPress, icon, text }: Props) {
const { colors } = useTheme();
return (
<TouchableWithoutFeedback onPress={onPress}>
<View style={{ alignItems: 'center' }}>
<View style={{ alignItems: 'center', width: '25%' }}>
<View style={[Styles.btnFiturMenu, { backgroundColor: colors.card, borderColor: colors.icon + '20', shadowColor: colors.text }]}>
{icon}
</View>
<Text style={[Styles.mt05, { color: colors.text }]}>{text}</Text>
<Text style={[Styles.mt05, { color: colors.text, textAlign: 'center', fontSize: 12 }]}>{text}</Text>
</View>
</TouchableWithoutFeedback>
)

View File

@@ -10,20 +10,22 @@ type Props = {
round?: boolean
error?: boolean
errorText?: string
disabled?: boolean
}
export default function ButtonSelect({ value, onPress, round, error, errorText }: Props) {
export default function ButtonSelect({ value, onPress, round, error, errorText, disabled }: Props) {
const { colors } = useTheme();
return (
<View style={[Styles.mv05]}>
<Pressable onPress={onPress}>
<Pressable onPress={onPress} disabled={disabled}>
<View style={[
Styles.inputRoundForm,
Styles.inputRoundFormRight,
round && Styles.round30,
Styles.pv10,
{ borderColor: colors.icon + '20', backgroundColor: colors.input },
error && { borderColor: "red" }
error && { borderColor: "red" },
disabled && { opacity: 0.4 }
]}>
<Feather name="arrow-right-circle" size={20} color={colors.text} />
<Text style={[{ color: colors.text }]}>{value}</Text>

View File

@@ -6,19 +6,28 @@ import Text from "../Text";
type Props = {
text: string;
isSelected: boolean;
isSign: boolean;
isSign?: boolean;
isSignCalendar?: boolean;
isSignTask?: boolean;
onPress?: () => void;
}
export default function ItemDateCalendar({ text, isSelected, isSign, onPress }: Props) {
export default function ItemDateCalendar({ text, isSelected, isSign, isSignCalendar, isSignTask, onPress }: Props) {
const { colors } = useTheme()
const showMulti = isSignCalendar !== undefined || isSignTask !== undefined;
return (
<>
<Pressable style={{ alignItems: 'center' }} onPress={onPress}>
<Text style={[isSelected ? Styles.cWhite : { color: colors.text }]}>{text}</Text>
<View style={[Styles.signDate, { backgroundColor: isSign ? 'red' : 'transparent' }]}></View>
</Pressable>
</>
<Pressable style={Styles.contentItemCenter} onPress={onPress}>
<Text style={[isSelected ? Styles.cWhite : { color: colors.text }]}>{text}</Text>
{showMulti ? (
<View style={Styles.calendarDotRow}>
<View style={[Styles.calendarDot, { backgroundColor: isSignCalendar ? (isSelected ? 'white' : '#7886C7') : 'transparent' }]} />
<View style={[Styles.calendarDot, { backgroundColor: isSignTask ? (isSelected ? 'white' : '#94B4C1') : 'transparent' }]} />
</View>
) : (
<View style={[Styles.signDate, { backgroundColor: isSign ? 'red' : 'transparent' }]} />
)}
</Pressable>
)
}

View File

@@ -14,9 +14,11 @@ type Props = {
height?: number
backdropPressable?: boolean
keyboard?: boolean
scrollOffset?: number
scrollTo?: (p: any) => void
}
export default function DrawerBottom({ isVisible, setVisible, title, children, animation, height, backdropPressable = true, keyboard = false }: Props) {
export default function DrawerBottom({ isVisible, setVisible, title, children, animation, height, backdropPressable = true, keyboard = false, scrollOffset, scrollTo }: Props) {
const tinggiScreen = Dimensions.get("window").height;
const { colors } = useTheme();
const tinggiInput = height != undefined ? height : 25
@@ -38,6 +40,9 @@ export default function DrawerBottom({ isVisible, setVisible, title, children, a
backdropTransitionOutTiming={500}
useNativeDriverForBackdrop={true}
propagateSwipe={true}
scrollTo={scrollTo}
scrollOffset={scrollOffset}
scrollOffsetMax={200}
>
{
keyboard ?

View File

@@ -17,16 +17,16 @@ export default function EventItem({ category, title, user, jamAwal, jamAkhir, on
const getBackgroundColor = (cat: 'purple' | 'orange') => {
if (activeTheme === 'dark') {
return cat === 'orange' ? '#547792' : '#1D546D';
return cat === 'purple' ? '#2D2B5E' : '#1C3347';
}
return cat === 'orange' ? '#D6E6F2' : '#A9B5DF';
return cat === 'purple' ? '#A9B5DF' : '#D6E6F2';
};
const getStickColor = (cat: 'purple' | 'orange') => {
if (activeTheme === 'dark') {
return cat === 'orange' ? '#94B4C1' : '#5F9598';
return cat === 'purple' ? '#7886C7' : '#94B4C1';
}
return cat === 'orange' ? '#F5F5F5' : '#7886C7';
return cat === 'purple' ? '#7886C7' : '#94B4C1';
};
return (

View File

@@ -36,7 +36,7 @@ export default function CaraouselHome({ refreshing }: { refreshing: boolean }) {
async function handleUser() {
const hasil = await decryptToken(String(token?.current))
const response = await apiGetProfile({ id: hasil })
dispatch(setEntityUser({ role: response.data.idUserRole, admin: false }))
dispatch(setEntityUser({ role: response.data.idUserRole, admin: false, isApprover: response.data.isApprover ?? false, idGroup: response.data.idGroup ?? '' }))
}
useEffect(() => {

View File

@@ -59,7 +59,7 @@ export default function CaraouselHome2({ refreshing }: { refreshing: boolean })
// Sync User Role to Redux
useEffect(() => {
if (profile) {
dispatch(setEntityUser({ role: profile.idUserRole, admin: false }))
dispatch(setEntityUser({ role: profile.idUserRole, admin: false, isApprover: profile.isApprover ?? false, idGroup: profile.idGroup ?? '' }))
}
}, [profile, dispatch])

View File

@@ -1,4 +1,5 @@
import Styles from "@/constants/Styles";
import { useTheme } from "@/providers/ThemeProvider";
import { useState } from "react";
import { Image } from "react-native";
@@ -9,12 +10,16 @@ type Props = {
onError?: (val:boolean) => void
}
export default function ImageUser({ src, size, onError }: Props) {
export default function ImageUser({ src, size, border = true, onError }: Props) {
const [error, setError] = useState(false)
const { colors } = useTheme()
return (
<Image
source={error ? require('../assets/images/user.jpg') : { uri: src }}
style={[size == 'xs' ? Styles.userProfileExtraSmall : size == 'lg' ? Styles.userProfileBig : Styles.userProfileSmall, Styles.borderAll]}
style={[
size == 'xs' ? Styles.userProfileExtraSmall : size == 'lg' ? Styles.userProfileBig : Styles.userProfileSmall,
border && { borderWidth: 1, borderColor: colors.icon + '40', borderRadius: 100 }
]}
onError={() => {
setError(true)
onError?.(true)

View File

@@ -30,6 +30,10 @@ export function InputForm({ label, value, placeholder, onChange, info, disable,
const { colors } = useTheme();
if (itemLeft != undefined || itemRight != undefined) {
const hasBothItems = itemLeft != undefined && itemRight != undefined;
const baseInputWidth = width ? lebar * width / 100 : lebar * 0.78;
// When both icons present, shrink TextInput by right icon size to keep container width stable
const textInputWidth = hasBothItems ? baseInputWidth - 30 : baseInputWidth;
return (
<View style={[mb && Styles.mb10]}>
{
@@ -42,7 +46,7 @@ export function InputForm({ label, value, placeholder, onChange, info, disable,
}
<View style={[
Styles.inputRoundForm,
itemRight != undefined ? Styles.inputRoundFormRight : Styles.inputRoundFormLeft,
hasBothItems ? Styles.inputRoundFormLeft : itemRight != undefined ? Styles.inputRoundFormRight : Styles.inputRoundFormLeft,
multiline && { alignItems: 'flex-end' },
round && Styles.round30,
{
@@ -50,12 +54,11 @@ export function InputForm({ label, value, placeholder, onChange, info, disable,
},
error ? { borderColor: colors.error } : { borderColor: colors.icon + '20' },
Platform.OS == 'ios' ? { paddingVertical: 10 } : { paddingVertical: 0, minHeight: 40 },
{ alignItems: 'center' },
multiline
? { alignItems: "flex-end" }
: { alignItems: "center" },
]}>
{itemRight != undefined ? itemRight : itemLeft}
{hasBothItems ? itemLeft : (itemRight != undefined ? itemRight : itemLeft)}
<TextInput
editable={!disable}
value={value}
@@ -68,10 +71,11 @@ export function InputForm({ label, value, placeholder, onChange, info, disable,
style={[
Styles.mh05,
multiline && { height: '100%', maxHeight: 100 },
{ width: width ? lebar * width / 100 : lebar * 0.78, color: colors.text },
{ width: textInputWidth, color: colors.text },
Platform.OS == 'ios' ? { paddingVertical: 1, paddingTop: 4 } : { paddingVertical: 0 },
]}
/>
{hasBothItems && itemRight}
</View>
{error && (<Text style={[Styles.textInformation, { color: colors.error }, Styles.mt05]}>{errorText}</Text>)}
{info != undefined && (<Text style={[Styles.textInformation, { color: colors.dimmed }, Styles.mt05]}>{info}</Text>)}

View File

@@ -1,19 +1,42 @@
import { useTheme } from "@/providers/ThemeProvider";
import { Feather } from "@expo/vector-icons";
import { useState } from "react";
import { TouchableOpacity } from "react-native";
import { InputForm } from "./inputForm";
export default function InputSearch({ onChange, width, value, bg }: { onChange?: (val: string) => void, width?: number, value?: string, bg?: string }) {
const { colors } = useTheme();
const [internalValue, setInternalValue] = useState(value ?? "");
const displayValue = value !== undefined ? value : internalValue;
const handleChange = (val: string) => {
setInternalValue(val);
onChange?.(val);
};
const handleClear = () => {
setInternalValue("");
onChange?.("");
};
return (
<InputForm
type="default"
placeholder="Pencarian"
round
itemLeft={<Feather name="search" size={20} color={colors.dimmed} />}
onChange={onChange}
itemRight={
displayValue ? (
<TouchableOpacity onPress={handleClear}>
<Feather name="x" size={20} color={colors.dimmed} />
</TouchableOpacity>
) : undefined
}
onChange={handleChange}
width={width}
bg={bg}
value={value}
value={displayValue}
mb={false}
/>
)

View File

@@ -1,62 +1,192 @@
import Styles from "@/constants/Styles";
import { useTheme } from "@/providers/ThemeProvider";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { Pressable, View } from "react-native";
import { useState } from "react";
import { LayoutChangeEvent, Pressable, View } from "react-native";
import Text from "./Text";
type FileItem = {
name: string
extension: string
}
type Props = {
done?: boolean
status?: number // 0=belum selesai, 1=selesai, 2=menunggu persetujuan
title: string
dateStart: string
dateEnd: string
files?: FileItem[]
onPress?: () => void
}
export default function ItemSectionTanggalTugas({ done, title, dateStart, dateEnd, onPress }: Props) {
const { colors } = useTheme();
const CHAR_W = 6.5
const ICON_W = 17
const PAD_H = 16
const GAP = 6
const PLUS_W = 72
function estimateChipWidth(label: string) {
return PAD_H + ICON_W + label.length * CHAR_W
}
function getVisibleChips(files: FileItem[], containerWidth: number) {
if (containerWidth === 0) return { visible: [], extra: files.length }
let used = 0
const visible: FileItem[] = []
for (let i = 0; i < files.length; i++) {
const label = `${files[i].name}.${files[i].extension}`
const chipW = estimateChipWidth(label)
const isLast = i === files.length - 1
const plusChipW = isLast ? 0 : PLUS_W + GAP
const gapW = visible.length > 0 ? GAP : 0
if (used + gapW + chipW + plusChipW <= containerWidth) {
visible.push(files[i])
used += gapW + chipW
} else {
break
}
}
return { visible, extra: files.length - visible.length }
}
function getFileIcon(extension: string): keyof typeof MaterialCommunityIcons.glyphMap {
const ext = extension.toLowerCase()
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
const AMBER = '#FFA94D'
function getStatusStyle(status: number | undefined, successColor: string, dimmed: string) {
if (status === 1) return { accent: successColor, badge: successColor + '25', text: successColor, label: 'Selesai' }
if (status === 2) return { accent: AMBER, badge: AMBER + '25', text: AMBER, label: 'Menunggu Persetujuan' }
return { accent: dimmed + '80', badge: dimmed + '18', text: dimmed, label: 'Belum Selesai' }
}
export default function ItemSectionTanggalTugas({ status, title, dateStart, dateEnd, files = [], onPress }: Props) {
const { colors, activeTheme } = useTheme()
const [containerWidth, setContainerWidth] = useState(0)
const { visible, extra } = getVisibleChips(files, containerWidth)
function onChipsLayout(e: LayoutChangeEvent) {
const w = e.nativeEvent.layout.width
if (w !== containerWidth) setContainerWidth(w)
}
const dimmed = colors.dimmed.slice(0, 7)
const successColor = activeTheme === 'dark' ? '#51CF66' : colors.success
const statusStyle = getStatusStyle(status, successColor, dimmed)
return (
<Pressable style={[Styles.mb15, { borderBottomColor: colors.icon + '20', borderBottomWidth: 1 }]} onPress={onPress}>
<View style={[Styles.rowItemsCenter]}>
{
done != undefined ?
done ?
<>
<MaterialCommunityIcons name="checkbox-marked-circle-outline" size={22} color={colors.text} style={[Styles.mr10]} />
<Text>Selesai</Text>
</>
:
<>
<MaterialCommunityIcons name="checkbox-blank-circle-outline" size={22} color={colors.text} style={[Styles.mr10]} />
<Text>Belum Selesai</Text>
</>
:
<></>
}
<Pressable
onPress={onPress}
style={{
flexDirection: 'row',
borderRadius: 10,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.icon + '18',
backgroundColor: colors.card,
marginBottom: 10,
}}
>
{/* Accent bar kiri */}
{status !== undefined && (
<View style={{ width: 4, backgroundColor: statusStyle.accent }} />
)}
</View>
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.borderAll, Styles.mv10, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
<View style={[Styles.rowItemsCenter, { alignItems: 'flex-start' }]}>
<MaterialCommunityIcons name="file-table-outline" size={25} color={colors.text} style={[Styles.mr10]} />
<View style={[Styles.w90]}>
<Text style={[Styles.textDefault]}>{title}</Text>
</View>
{/* Konten */}
<View style={{ flex: 1, padding: 12 }}>
{/* Judul + badge status */}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<Text style={[Styles.textDefault, { flex: 1, marginRight: 8 }]}>{title}</Text>
{status !== undefined && (
<View style={{
backgroundColor: statusStyle.badge,
borderRadius: 20,
paddingHorizontal: 8,
paddingVertical: 3,
alignSelf: 'flex-start',
}}>
<Text style={[Styles.textSmallSemiBold, { color: statusStyle.text }]}>
{statusStyle.label}
</Text>
</View>
)}
</View>
</View>
<View style={[Styles.rowSpaceBetween, Styles.mb15]}>
<View style={[{ width: '48%' }]}>
<Text style={[Styles.mb05]}>Tanggal Mulai</Text>
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.borderAll, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
<Text style={{ textAlign: 'center' }}>{dateStart}</Text>
</View>
{/* Tanggal */}
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: files.length > 0 ? 8 : 0 }}>
<MaterialCommunityIcons name="calendar-outline" size={13} color={colors.dimmed} style={{ marginRight: 4 }} />
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{dateStart}</Text>
<MaterialCommunityIcons name="arrow-right" size={13} color={colors.dimmed} style={{ marginHorizontal: 4 }} />
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>{dateEnd}</Text>
</View>
<View style={[{ width: '48%' }]}>
<Text style={[Styles.mb05]}>Tanggal Berakhir</Text>
<View style={[Styles.wrapPaper, Styles.noShadow, Styles.borderAll, Styles.p10, { backgroundColor: colors.card, borderColor: colors.icon + '20' }]}>
<Text style={{ textAlign: 'center' }}>{dateEnd}</Text>
{/* Chips lampiran */}
{files.length > 0 && (
<View
style={{ flexDirection: 'row', gap: GAP, overflow: 'hidden' }}
onLayout={onChipsLayout}
>
{visible.map((file, index) => {
const label = `${file.name}.${file.extension}`
const chipW = Math.min(estimateChipWidth(label), containerWidth * 0.55)
return (
<View
key={index}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: dimmed + '18',
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 3,
width: chipW,
}}
>
<MaterialCommunityIcons
name={getFileIcon(file.extension)}
size={13}
color={colors.dimmed}
style={{ marginRight: 4 }}
/>
<Text
style={[Styles.textSmallSemiBold, { color: colors.dimmed, flex: 1 }]}
numberOfLines={1}
ellipsizeMode="tail"
>
{label}
</Text>
</View>
)
})}
{extra > 0 && (
<View style={{
backgroundColor: dimmed + '18',
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 3,
}}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>
+{extra} lainnya
</Text>
</View>
)}
</View>
</View>
)}
</View>
</Pressable>
)
}
}

View File

@@ -1,5 +1,5 @@
import Styles from "@/constants/Styles"
import { apiDeleteUser } from "@/lib/api"
import { apiDeleteUser, apiToggleApprover } from "@/lib/api"
import { setUpdateMember } from "@/lib/memberSlice"
import { useAuthSession } from "@/providers/AuthProvider"
import { useTheme } from "@/providers/ThemeProvider"
@@ -16,14 +16,17 @@ import MenuItemRow from "../menuItemRow"
type Props = {
active: any,
id: string
id: string,
isApprover: boolean,
}
export default function HeaderRightMemberDetail({ active, id }: Props) {
export default function HeaderRightMemberDetail({ active, id, isApprover }: Props) {
const { token, decryptToken } = useAuthSession()
const [isVisible, setVisible] = useState(false)
const update = useSelector((state: any) => state.memberUpdate)
const [showModal, setShowModal] = useState(false)
const entityUser = useSelector((state: any) => state.user)
const [showModalActive, setShowModalActive] = useState(false)
const [showModalApprover, setShowModalApprover] = useState(false)
const { colors } = useTheme();
const dispatch = useDispatch()
@@ -37,17 +40,36 @@ export default function HeaderRightMemberDetail({ active, id }: Props) {
} else {
Toast.show({ type: 'small', text1: response.message, })
}
} catch (error : any ) {
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengupdate data"
Toast.show({ type: 'small', text1: message })
} finally {
setVisible(false)
}
}
async function handleToggleApprover() {
try {
const hasil = await decryptToken(String(token?.current))
const response = await apiToggleApprover({ user: hasil, isApprover: !isApprover }, id)
if (response.success) {
Toast.show({ type: 'small', text1: response.message })
dispatch(setUpdateMember(!update))
} else {
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengupdate data"
Toast.show({ type: 'small', text1: message })
} finally {
setShowModalApprover(false)
}
}
const canManageApprover = ['supadmin', 'developer'].includes(entityUser.role)
return (
<>
<ButtonMenuHeader onPress={() => { setVisible(true) }} />
@@ -55,12 +77,10 @@ export default function HeaderRightMemberDetail({ active, id }: Props) {
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<MaterialCommunityIcons name="toggle-switch-off-outline" color={colors.text} size={25} />}
title={active ? "Non Aktifkan" : "Aktifkan"}
title={active ? "Nonaktifkan" : "Aktifkan"}
onPress={() => {
setVisible(false)
setTimeout(() => {
setShowModal(true)
}, 600)
setTimeout(() => setShowModalActive(true), 600)
}}
/>
<MenuItemRow
@@ -71,18 +91,39 @@ export default function HeaderRightMemberDetail({ active, id }: Props) {
router.push(`/member/edit/${id}`)
}}
/>
{canManageApprover && (
<MenuItemRow
icon={<MaterialCommunityIcons name="shield-check-outline" color={colors.text} size={25} />}
title={isApprover ? "Revoke Approver" : "Jadikan Approver"}
color={colors.text}
onPress={() => {
setVisible(false)
setTimeout(() => setShowModalApprover(true), 600)
}}
/>
)}
</View>
</DrawerBottom>
<ModalConfirmation
visible={showModal}
visible={showModalActive}
title="Konfirmasi"
message={active ? 'Apakah anda yakin ingin menonaktifkan user?' : 'Apakah anda yakin ingin mengaktifkan user?'}
message={active ? 'Apakah anda yakin ingin menonaktifkan anggota ini?' : 'Apakah anda yakin ingin mengaktifkan anggota ini?'}
onConfirm={() => {
setShowModal(false)
setShowModalActive(false)
handleActive()
}}
onCancel={() => setShowModal(false)}
onCancel={() => setShowModalActive(false)}
confirmText="Konfirmasi"
cancelText="Batal"
/>
<ModalConfirmation
visible={showModalApprover}
title="Konfirmasi"
message={isApprover ? 'Apakah anda yakin ingin mencabut status approver user ini?' : 'Apakah anda yakin ingin menjadikan user ini sebagai approver?'}
onConfirm={handleToggleApprover}
onCancel={() => setShowModalApprover(false)}
confirmText="Konfirmasi"
cancelText="Batal"
/>

View File

@@ -108,12 +108,12 @@ export default function ModalSelect({ open, close, title, category, idParent, on
setChooseValue({ ...chooseValue, val: valChoose })
}, [dispatch, open, search]);
function onChoose(val: string, label: string, img?: string) {
function onChoose(val: string, label: string, img?: string, position?: string) {
if (category == "member") {
if (selectMember.some((i: any) => i.idUser == val)) {
setSelectMember(selectMember.filter((i: any) => i.idUser != val))
} else {
setSelectMember([...selectMember, { idUser: val, name: label, img }])
setSelectMember([...selectMember, { idUser: val, name: label, img, position }])
}
} else {
setChooseValue({ val, label })
@@ -144,7 +144,7 @@ export default function ModalSelect({ open, close, title, category, idParent, on
key={index}
label={item.name}
src={`${ConstEnv.url_storage}/files/${item.img}`}
onClick={() => onChoose(item.idUser, item.name, item.img)}
onClick={() => onChoose(item.idUser, item.name, item.img, item.position)}
/>
))
}
@@ -162,7 +162,7 @@ export default function ModalSelect({ open, close, title, category, idParent, on
category != 'status-task' ?
data.length > 0 ?
data.map((item: any, index: any) => (
<Pressable key={index} style={[Styles.itemSelectModal, {borderColor:colors.icon+'20'}]} onPress={() => { onChoose(item.id, item.name, item.img) }}>
<Pressable key={index} style={[Styles.itemSelectModal, {borderColor:colors.icon+'20'}]} onPress={() => { onChoose(item.id, item.name, item.img, item.position) }}>
{
category == 'member'
?

View File

@@ -0,0 +1,100 @@
import { urlCompleted } from "@/lib/fun_urlCompleted";
import { useTheme } from "@/providers/ThemeProvider";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { Linking, Pressable, View } from "react-native";
import Text from "../Text";
import Styles from "@/constants/Styles";
type Props = {
link: string
canDelete: boolean
onLongPress: () => void
}
type DomainConfig = {
icon: keyof typeof MaterialCommunityIcons.glyphMap
color: string
label: string
}
function getDomainConfig(url: string): DomainConfig {
try {
const hostname = new URL(urlCompleted(url)).hostname.replace('www.', '')
if (hostname.includes('youtube.com') || hostname.includes('youtu.be'))
return { icon: 'youtube', color: '#FF0000', label: 'YouTube' }
if (hostname.includes('drive.google.com'))
return { icon: 'google-drive', color: '#4285F4', label: 'Google Drive' }
if (hostname.includes('docs.google.com'))
return { icon: 'google', color: '#4285F4', label: 'Google Docs' }
if (hostname.includes('sheets.google.com'))
return { icon: 'google-spreadsheet', color: '#0F9D58', label: 'Google Sheets' }
if (hostname.includes('github.com'))
return { icon: 'github', color: '#24292E', label: 'GitHub' }
if (hostname.includes('wa.me') || hostname.includes('whatsapp.com'))
return { icon: 'whatsapp', color: '#25D366', label: 'WhatsApp' }
if (hostname.includes('instagram.com'))
return { icon: 'instagram', color: '#E1306C', label: 'Instagram' }
if (hostname.includes('facebook.com'))
return { icon: 'facebook', color: '#1877F2', label: 'Facebook' }
if (hostname.includes('figma.com'))
return { icon: 'vector-bezier', color: '#F24E1E', label: 'Figma' }
if (hostname.includes('notion.so'))
return { icon: 'notebook-outline', color: '#000000', label: 'Notion' }
return { icon: 'link-variant', color: '#6366F1', label: hostname }
} catch {
return { icon: 'link-variant', color: '#6366F1', label: url }
}
}
function getDisplayUrl(url: string) {
try {
const full = urlCompleted(url)
const parsed = new URL(full)
const path = parsed.pathname + parsed.search
return path.length > 1 ? path : ''
} catch {
return ''
}
}
export default function ItemSectionLink({ link, canDelete, onLongPress }: Props) {
const { colors, activeTheme } = useTheme()
const config = getDomainConfig(link)
const displayPath = getDisplayUrl(link)
const iconBg = activeTheme === 'dark' ? config.color + '25' : config.color + '15'
const iconColor = activeTheme === 'dark' && config.color === '#24292E' ? '#ECEDEE' : config.color
return (
<Pressable
onPress={() => Linking.openURL(urlCompleted(link))}
onLongPress={canDelete ? onLongPress : undefined}
style={({ pressed }) => ([
Styles.fileCard,
{
width: '100%',
marginBottom: 10,
borderColor: colors.icon + '18',
backgroundColor: pressed ? colors.icon + '10' : colors.card,
},
])}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconBg }]}>
<MaterialCommunityIcons name={config.icon} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]} numberOfLines={1}>
{config.label}
</Text>
{displayPath.length > 0 && (
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed, marginTop: 2 }]} numberOfLines={1} ellipsizeMode="tail">
{displayPath}
</Text>
)}
</View>
<MaterialCommunityIcons name="arrow-top-right" size={16} color={colors.dimmed} />
</Pressable>
)
}

View File

@@ -10,19 +10,17 @@ import { startActivityAsync } from 'expo-intent-launcher';
import { useLocalSearchParams } from "expo-router";
import * as Sharing from 'expo-sharing';
import { useEffect, useState } from "react";
import { Alert, Platform, View } from "react-native";
import { Alert, Platform, Pressable, View } from "react-native";
import * as mime from 'react-native-mime-types';
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
import ModalConfirmation from "../ModalConfirmation";
import BorderBottomItem from "../borderBottomItem";
import DrawerBottom from "../drawerBottom";
import MenuItemRow from "../menuItemRow";
import ModalConfirmation from "../ModalConfirmation";
import ModalLoading from "../modalLoading";
import Skeleton from "../skeleton";
import Text from "../Text";
type Props = {
id: string
name: string
@@ -30,6 +28,28 @@ type Props = {
idStorage: string
}
function getFileIcon(extension: string): keyof typeof MaterialCommunityIcons.glyphMap {
const ext = extension.toLowerCase()
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(extension: string): string {
const ext = extension.toLowerCase()
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function SectionFile({ status, member, refreshing }: { status: number | undefined, member: boolean, refreshing?: boolean }) {
const { colors } = useTheme();
const entityUser = useSelector((state: any) => state.user)
@@ -40,7 +60,7 @@ export default function SectionFile({ status, member, refreshing }: { status: nu
const update = useSelector((state: any) => state.projectUpdate)
const dispatch = useDispatch()
const [loading, setLoading] = useState(true)
const arrSkeleton = Array.from({ length: 3 })
const arrSkeleton = Array.from({ length: 4 })
const [selectFile, setSelectFile] = useState<Props | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [loadingOpen, setLoadingOpen] = useState(false)
@@ -49,11 +69,7 @@ export default function SectionFile({ status, member, refreshing }: { status: nu
try {
setLoading(loading)
const hasil = await decryptToken(String(token?.current));
const response = await apiGetProjectOne({
user: hasil,
cat: "file",
id: id,
});
const response = await apiGetProjectOne({ user: hasil, cat: "file", id });
setData(response.data);
} catch (error) {
console.error(error);
@@ -62,110 +78,90 @@ export default function SectionFile({ status, member, refreshing }: { status: nu
}
}
useEffect(() => {
handleLoad(false);
}, [update.file]);
useEffect(() => {
if (refreshing)
handleLoad(false);
}, [refreshing]);
useEffect(() => {
handleLoad(true);
}, []);
useEffect(() => { handleLoad(false) }, [update.file]);
useEffect(() => { if (refreshing) handleLoad(false) }, [refreshing]);
useEffect(() => { handleLoad(true) }, []);
async function handleDelete() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiDeleteFileProject({ user: hasil }, String(selectFile?.id));
if (response.success) {
Toast.show({ type: 'small', text1: 'Berhasil menghapus file', })
Toast.show({ type: 'small', text1: 'Berhasil menghapus file' })
dispatch(setUpdateProject({ ...update, file: !update.file }))
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error : any ) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menghapus file"
Toast.show({ type: 'small', text1: message })
} catch (error: any) {
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menghapus file" })
} finally {
setModal(false)
}
}
const openFile = () => {
setModal(false)
setLoadingOpen(true)
let remoteUrl = ConstEnv.url_storage + '/files/' + selectFile?.idStorage;
const fileName = selectFile?.name + '.' + selectFile?.extension;
let localPath = `${FileSystem.documentDirectory}/${fileName}`;
const remoteUrl = ConstEnv.url_storage + '/files/' + selectFile?.idStorage
const fileName = selectFile?.name + '.' + selectFile?.extension
const localPath = `${FileSystem.documentDirectory}/${fileName}`
const mimeType = mime.lookup(fileName)
FileSystem.downloadAsync(remoteUrl, localPath).then(async ({ uri }) => {
const contentURL = await FileSystem.getContentUriAsync(uri);
const contentURL = await FileSystem.getContentUriAsync(uri)
try {
if (Platform.OS == 'android') {
// open with android intent
await startActivityAsync(
'android.intent.action.VIEW',
{
data: contentURL,
flags: 1,
type: mimeType as string,
}
);
// or
// Sharing.shareAsync(localPath);
} else if (Platform.OS == 'ios') {
Sharing.shareAsync(localPath);
if (Platform.OS === 'android') {
await startActivityAsync('android.intent.action.VIEW', { data: contentURL, flags: 1, type: mimeType as string })
} else {
Sharing.shareAsync(localPath)
}
} catch (error) {
Alert.alert('INFO', 'Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini');
} catch {
Alert.alert('INFO', 'Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini')
} finally {
setLoadingOpen(false)
}
});
};
})
}
return (
<>
<ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} />
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>File</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<Skeleton key={index} width={100} height={40} widthType="percent" borderRadius={10} />
)
})
:
data.length > 0 ?
data.map((item, index) => {
return (
<BorderBottomItem
key={index}
borderType="all"
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + '.' + item.extension}
titleWeight="normal"
onPress={() => { setSelectFile(item); setModal(true) }}
/>
)
})
:
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada file</Text>
}
</View>
{loading ? (
<View style={Styles.fileGrid}>
{arrSkeleton.map((_, index) => (
<Skeleton key={index} width={48} height={68} widthType="percent" borderRadius={10} />
))}
</View>
) : data.length > 0 ? (
<View style={Styles.fileGrid}>
{data.map((item, index) => {
const iconName = getFileIcon(item.extension)
const iconColor = getFileColor(item.extension)
return (
<Pressable
key={index}
onPress={() => { setSelectFile(item); setModal(true) }}
style={[Styles.fileCard, { backgroundColor: colors.card, borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>
{item.extension.toUpperCase()}
</Text>
</View>
</Pressable>
)
})}
</View>
) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada file</Text>
)}
</View>
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu">
@@ -173,26 +169,20 @@ export default function SectionFile({ status, member, refreshing }: { status: nu
<MenuItemRow
icon={<MaterialCommunityIcons name="file-eye" color={colors.text} size={25} />}
title="Lihat / Share"
onPress={() => {
openFile()
}}
onPress={openFile}
/>
{
!member && (entityUser.role == "user" || entityUser.role == "coadmin") ? <></>
:
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
disabled={status == 3}
onPress={() => {
if (status == 3) return
setModal(false)
setTimeout(() => {
setShowDeleteModal(true)
}, 600)
}}
/>
}
{(!member && (entityUser.role === "user" || entityUser.role === "coadmin")) ? null : (
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
disabled={status === 3}
onPress={() => {
if (status === 3) return
setModal(false)
setTimeout(() => setShowDeleteModal(true), 600)
}}
/>
)}
</View>
</DrawerBottom>
@@ -200,14 +190,11 @@ export default function SectionFile({ status, member, refreshing }: { status: nu
visible={showDeleteModal}
title="Konfirmasi"
message="Apakah Anda yakin ingin menghapus file ini? File yang dihapus tidak dapat dikembalikan"
onConfirm={() => {
setShowDeleteModal(false)
handleDelete()
}}
onConfirm={() => { setShowDeleteModal(false); handleDelete() }}
onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus"
cancelText="Batal"
/>
</>
)
}
}

View File

@@ -1,20 +1,19 @@
import Styles from "@/constants/Styles";
import { apiDeleteLinkProject, apiGetProjectOne } from "@/lib/api";
import { urlCompleted } from "@/lib/fun_urlCompleted";
import { setUpdateProject } from "@/lib/projectUpdate";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { Linking, View } from "react-native";
import { View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
import ModalConfirmation from "../ModalConfirmation";
import BorderBottomItem from "../borderBottomItem";
import DrawerBottom from "../drawerBottom";
import MenuItemRow from "../menuItemRow";
import Text from "../Text";
import ItemSectionLink from "./itemSectionLink";
type Props = {
@@ -87,17 +86,16 @@ export default function SectionLink({ status, member, refreshing }: { status: nu
<>
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>Link</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
<View>
{
data.map((item, index) => {
const canDelete = member || (entityUser.role !== "user" && entityUser.role !== "coadmin")
return (
<BorderBottomItem
<ItemSectionLink
key={index}
borderType="all"
icon={<Feather name="link" size={25} color={colors.text} />}
title={item.link}
titleWeight="normal"
onPress={() => { setSelectLink(item); setModal(true) }}
link={item.link}
canDelete={canDelete && status !== 3}
onLongPress={() => { setSelectLink(item); setModal(true) }}
/>
)
})
@@ -108,28 +106,13 @@ export default function SectionLink({ status, member, refreshing }: { status: nu
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu">
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<Feather name="external-link" color={colors.text} size={25} />}
title="Buka Link"
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus Link"
onPress={() => {
Linking.openURL(urlCompleted(String(selectLink?.link)))
setModal(false)
setTimeout(() => setShowDeleteModal(true), 600)
}}
/>
{
!member && (entityUser.role == "user" || entityUser.role == "coadmin") ? <></>
:
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
disabled={status == 3}
onPress={() => {
if (status == 3) return
setModal(false)
setTimeout(() => {
setShowDeleteModal(true)
}, 600)
}}
/>
}
</View>
</DrawerBottom>

View File

@@ -10,7 +10,7 @@ import ItemSectionTanggalTugas from "../itemSectionTanggalTugas";
import MenuItemRow from "../menuItemRow";
import Text from "../Text";
export default function SectionListAddTask() {
export default function SectionListAddTask({ showTitle = true }: { showTitle?: boolean }) {
const { colors } = useTheme();
const taskCreate = useSelector((state: any) => state.taskCreate)
const [select, setSelect] = useState<any>(null)
@@ -22,42 +22,32 @@ export default function SectionListAddTask() {
setModal(false)
}
const items = taskCreate.map((item: { status: number; title: string; dateStart: string; dateEnd: string; }, index: Key | null | undefined) => (
<ItemSectionTanggalTugas
key={index}
title={item.title}
dateStart={item.dateStart}
dateEnd={item.dateEnd}
onPress={() => {
setSelect(index)
setModal(true)
}}
/>
))
return (
<>
{
taskCreate.length > 0
&&
{taskCreate.length > 0 && (
<>
<View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>
Tanggal & Tugas
</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
taskCreate.map((item: { status: number; title: string; dateStart: string; dateEnd: string; }, index: Key | null | undefined) => {
return (
<ItemSectionTanggalTugas
key={index}
title={item.title}
dateStart={item.dateStart}
dateEnd={item.dateEnd}
onPress={() => {
setSelect(index)
setModal(true)
}}
/>
);
})
}
{showTitle ? (
<View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>Tanggal & Tugas</Text>
{items}
</View>
</View>
<DrawerBottom
animation="slide"
isVisible={isModal}
setVisible={setModal}
title="Menu"
>
) : (
<View>{items}</View>
)}
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu">
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
@@ -67,7 +57,7 @@ export default function SectionListAddTask() {
</View>
</DrawerBottom>
</>
}
)}
</>
)
}

View File

@@ -7,15 +7,14 @@ import { useTheme } from "@/providers/ThemeProvider";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { View } from "react-native";
import { Pressable, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
import ModalConfirmation from "../ModalConfirmation";
import BorderBottomItem from "../borderBottomItem";
import DrawerBottom from "../drawerBottom";
import ImageUser from "../imageNew";
import MenuItemRow from "../menuItemRow";
import SkeletonTwoItem from "../skeletonTwoItem";
import ModalConfirmation from "../ModalConfirmation";
import Skeleton from "../skeleton";
import Text from "../Text";
type Props = {
@@ -35,26 +34,17 @@ export default function SectionMember({ status, refreshing }: { status: number |
const [isModal, setModal] = useState(false);
const { token, decryptToken } = useAuthSession();
const { id } = useLocalSearchParams<{ id: string }>();
const [selectLink, setSelectLink] = useState<Props | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [data, setData] = useState<Props[]>([]);
const [loading, setLoading] = useState(true)
const arrSkeleton = Array.from({ length: 3 })
const [memberChoose, setMemberChoose] = useState({
id: '',
name: '',
})
const arrSkeleton = Array.from({ length: 4 })
const [memberChoose, setMemberChoose] = useState({ id: '', name: '' })
async function handleLoad(loading: boolean) {
try {
setLoading(loading)
const hasil = await decryptToken(String(token?.current));
const response = await apiGetProjectOne({
user: hasil,
cat: "member",
id: id,
});
const response = await apiGetProjectOne({ user: hasil, cat: "member", id });
setData(response.data);
} catch (error) {
console.error(error);
@@ -63,36 +53,21 @@ export default function SectionMember({ status, refreshing }: { status: number |
}
}
useEffect(() => {
handleLoad(false);
}, [update.member]);
useEffect(() => {
if (refreshing)
handleLoad(false);
}, [refreshing]);
useEffect(() => {
handleLoad(true);
}, []);
useEffect(() => { handleLoad(false) }, [update.member]);
useEffect(() => { if (refreshing) handleLoad(false) }, [refreshing]);
useEffect(() => { handleLoad(true) }, []);
async function handleDeleteMember() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiDeleteProjectMember({
user: hasil,
idUser: memberChoose.id,
}, id)
const response = await apiDeleteProjectMember({ user: hasil, idUser: memberChoose.id }, id)
if (response.success) {
Toast.show({ type: 'small', text1: 'Berhasil menghapus anggota', })
Toast.show({ type: 'small', text1: 'Berhasil menghapus anggota' })
dispatch(setUpdateProject({ ...update, member: !update.member }))
setModal(false);
}
} catch (error : any ) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menghapus anggota"
Toast.show({ type: 'small', text1: message })
} catch (error: any) {
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menghapus anggota" })
}
}
@@ -101,84 +76,85 @@ export default function SectionMember({ status, refreshing }: { status: number |
<View style={[Styles.mb15]}>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Anggota</Text>
<Text style={[Styles.textDefault]}>Total {data.length} Anggota</Text>
{!loading && data.length > 0 && (
<Text style={[Styles.textDefault, { color: colors.dimmed }]}>{data.length} orang</Text>
)}
</View>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<SkeletonTwoItem key={index} />
)
})
:
data.length > 0
?
data.map((item, index) => {
return (
<BorderBottomItem
key={index}
borderType="bottom"
icon={<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} />}
title={item.name}
onPress={() => {
if (status == 3) return
setMemberChoose({
id: item.idUser,
name: item.name,
})
setModal(true);
}}
/>
);
})
:
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada anggota</Text>
}
<View style={{ gap: 6 }}>
{loading ? (
arrSkeleton.map((_, index) => (
<View key={index} style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Skeleton width={40} height={40} borderRadius={20} />
<View style={{ flex: 1, gap: 5 }}>
<Skeleton width={60} height={12} widthType="percent" borderRadius={6} />
<Skeleton width={35} height={10} widthType="percent" borderRadius={6} />
</View>
</View>
))
) : data.length > 0 ? (
data.map((item, index) => (
<Pressable
key={index}
onPress={() => {
if (status === 3) return
setMemberChoose({ id: item.idUser, name: item.name })
setModal(true)
}}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.card,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.icon + '18',
paddingHorizontal: 12,
paddingVertical: 10,
gap: 12,
}}
>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<View style={{ flex: 1 }}>
<Text style={[Styles.textDefault]} numberOfLines={1}>{item.name}</Text>
</View>
<View style={{
backgroundColor: colors.dimmed + '15',
borderRadius: 20,
paddingHorizontal: 8,
paddingVertical: 3,
}}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]} numberOfLines={1}>
{item.position}
</Text>
</View>
</Pressable>
))
) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada anggota</Text>
)}
</View>
</View>
<DrawerBottom
animation="slide"
isVisible={isModal}
setVisible={setModal}
title={memberChoose.name}
>
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title={memberChoose.name}>
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={
<MaterialCommunityIcons
name="account-eye"
color={colors.text}
size={25}
/>
}
icon={<MaterialCommunityIcons name="account-eye" color={colors.text} size={25} />}
title="Lihat Profil"
onPress={() => {
setModal(false);
router.push(`/member/${memberChoose.id}`);
setModal(false)
router.push(`/member/${memberChoose.id}`)
}}
/>
{
entityUser.role != "user" && entityUser.role != "coadmin" &&
{entityUser.role !== "user" && entityUser.role !== "coadmin" && (
<MenuItemRow
icon={
<MaterialCommunityIcons
name="account-remove"
color={colors.text}
size={25}
/>
}
icon={<MaterialCommunityIcons name="account-remove" color={colors.text} size={25} />}
title="Keluarkan"
onPress={() => {
setModal(false)
setTimeout(() => {
setShowDeleteModal(true)
}, 600)
setTimeout(() => setShowDeleteModal(true), 600)
}}
/>
}
)}
</View>
</DrawerBottom>
@@ -186,10 +162,7 @@ export default function SectionMember({ status, refreshing }: { status: number |
visible={showDeleteModal}
title="Konfirmasi"
message="Apakah Anda yakin ingin mengeluarkan anggota?"
onConfirm={() => {
setShowDeleteModal(false)
handleDeleteMember()
}}
onConfirm={() => { setShowDeleteModal(false); handleDeleteMember() }}
onCancel={() => setShowDeleteModal(false)}
confirmText="Keluarkan"
cancelText="Batal"

View File

@@ -2,6 +2,7 @@ import Styles from "@/constants/Styles";
import { apiGetProjectOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { View } from "react-native";
@@ -19,39 +20,37 @@ export default function SectionReportProject({ refreshing }: { refreshing?: bool
async function handleLoad() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiGetProjectOne({
user: hasil,
cat: "data",
id: id,
});
const response = await apiGetProjectOne({ user: hasil, cat: "data", id: id });
setData(response.data.report);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
handleLoad();
}, [update.report]);
useEffect(() => { handleLoad() }, [update.report]);
useEffect(() => { if (refreshing) handleLoad() }, [refreshing]);
useEffect(() => {
if (refreshing)
handleLoad();
}, [refreshing]);
if (!data || data === "") return null;
return (
<>
{
data != "" && data != null &&
<View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>
Laporan Kegiatan
</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
<TextExpandable content={data} maxLines={2} />
</View>
<View style={[
Styles.wrapPaper,
Styles.mb15,
Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' },
]}>
<View style={Styles.sectionHeader}>
<View style={[Styles.sectionIconBox, Styles.mr10, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialCommunityIcons name="text-box-outline" size={18} color={colors.tabActive} />
</View>
}
</>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>
Laporan Kegiatan
</Text>
</View>
<View style={[Styles.reportContent, { borderLeftColor: colors.tabActive + '50' }]}>
<TextExpandable content={data} maxLines={2} />
</View>
</View>
);
}

View File

@@ -1,5 +1,5 @@
import Styles from "@/constants/Styles";
import { apiDeleteProjectTask, apiGetProjectOne, apiUpdateStatusProjectTask } from "@/lib/api";
import { apiApproveRejectProjectTask, apiDeleteProjectTask, apiGetProjectOne, apiGetProjectTaskApprovals, apiSubmitProjectTask } from "@/lib/api";
import { setUpdateProject } from "@/lib/projectUpdate";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
@@ -9,11 +9,12 @@ import { useEffect, useState } from "react";
import { View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
import ModalConfirmation from "../ModalConfirmation";
import DrawerBottom from "../drawerBottom";
import ItemSectionTanggalTugas from "../itemSectionTanggalTugas";
import MenuItemRow from "../menuItemRow";
import ModalSelect from "../modalSelect";
import ModalConfirmation from "../ModalConfirmation";
import ModalRiwayatApproval from "../ModalRiwayatApproval";
import ModalTolakApproval from "../ModalTolakApproval";
import SkeletonTask from "../skeletonTask";
import Text from "../Text";
import ModalListDetailTugasProject from "./modalListDetailTugasProject";
@@ -22,40 +23,52 @@ type Props = {
id: string;
title: string;
desc: string;
status: 1;
status: number;
dateStart: string;
dateEnd: string;
createdAt: string;
files?: { name: string; extension: string }[];
};
export default function SectionTanggalTugasProject({ status, member, refreshing }: { status: number | undefined, member: boolean, refreshing?: boolean }) {
type ApprovalRecord = {
id: string
status: number
note?: string
submitter: { name: string }
approver?: { name: string }
createdAt: string
}
export default function SectionTanggalTugasProject({ status, member, refreshing, idGroup }: { status: number | undefined, member: boolean, refreshing?: boolean, idGroup: string }) {
const { colors } = useTheme();
const entityUser = useSelector((state: any) => state.user)
const dispatch = useDispatch()
const update = useSelector((state: any) => state.projectUpdate)
const [isModal, setModal] = useState(false);
const [isSelect, setSelect] = useState(false);
const { token, decryptToken } = useAuthSession();
const [modalDetail, setModalDetail] = useState(false)
const [modalRiwayat, setModalRiwayat] = useState(false)
const [modalTolak, setModalTolak] = useState(false)
const [modalKonfirmasiSetujui, setModalKonfirmasiSetujui] = useState(false)
const [modalPersetujuan, setModalPersetujuan] = useState(false)
const { id } = useLocalSearchParams<{ id: string }>();
const [data, setData] = useState<Props[]>([]);
const [loading, setLoading] = useState(true)
const [loadingAction, setLoadingAction] = useState(false)
const [loadingRiwayat, setLoadingRiwayat] = useState(false)
const [riwayatData, setRiwayatData] = useState<ApprovalRecord[]>([])
const arrSkeleton = Array.from({ length: 5 });
const [tugas, setTugas] = useState({
id: '',
status: 0,
})
const [tugas, setTugas] = useState({ id: '', status: 0 })
const [showDeleteModal, setShowDeleteModal] = useState(false)
const isApprover = (entityUser.isApprover && entityUser.idGroup === idGroup) || ['supadmin', 'developer'].includes(entityUser.role)
const isAdmin = entityUser.role !== 'user' && entityUser.role !== 'coadmin'
async function handleLoad(loading: boolean) {
try {
setLoading(loading)
const hasil = await decryptToken(String(token?.current));
const response = await apiGetProjectOne({
user: hasil,
cat: "task",
id: id,
});
const response = await apiGetProjectOne({ user: hasil, cat: "task", id: id });
setData(response.data);
} catch (error) {
console.error(error);
@@ -64,167 +77,231 @@ export default function SectionTanggalTugasProject({ status, member, refreshing
}
}
useEffect(() => {
handleLoad(false);
}, [update.task]);
useEffect(() => { handleLoad(false) }, [update.task]);
useEffect(() => { if (refreshing) handleLoad(false) }, [refreshing]);
useEffect(() => { handleLoad(true) }, []);
useEffect(() => {
if (refreshing)
handleLoad(false);
}, [refreshing]);
useEffect(() => {
handleLoad(true);
}, []);
async function handleUpdate(status: number) {
async function handleLoadRiwayat() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiUpdateStatusProjectTask({
user: hasil,
idProject: id,
status: status,
}, tugas.id);
setLoadingRiwayat(true)
const hasil = await decryptToken(String(token?.current))
const response = await apiGetProjectTaskApprovals({ user: hasil, id: tugas.id })
setRiwayatData(response.data ?? [])
} catch (error) {
console.error(error)
} finally {
setLoadingRiwayat(false)
}
}
async function handleSubmitAjukan() {
try {
setLoadingAction(true)
const hasil = await decryptToken(String(token?.current))
const response = await apiSubmitProjectTask({ user: hasil, id: tugas.id })
if (response.success) {
dispatch(setUpdateProject({ ...update, progress: !update.progress, task: !update.task }))
setSelect(false);
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', })
Toast.show({ type: 'small', text1: 'Berhasil mengajukan task untuk persetujuan' })
} else {
Toast.show({ type: 'small', text1: response.message })
}
} catch (error : any ) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
} catch (error: any) {
const message = error?.response?.data?.message || "Gagal mengajukan persetujuan"
Toast.show({ type: 'small', text1: message })
} finally {
setLoadingAction(false)
setModal(false)
}
}
async function handleSetujui() {
try {
setLoadingAction(true)
const hasil = await decryptToken(String(token?.current))
const response = await apiApproveRejectProjectTask({ user: hasil, id: tugas.id, action: 'approve' })
if (response.success) {
dispatch(setUpdateProject({ ...update, progress: !update.progress, task: !update.task }))
Toast.show({ type: 'small', text1: 'Tugas berhasil disetujui' })
} else {
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
const message = error?.response?.data?.message || "Gagal menyetujui tugas"
Toast.show({ type: 'small', text1: message })
} finally {
setLoadingAction(false)
setModalKonfirmasiSetujui(false)
}
}
async function handleTolak(note: string) {
try {
setLoadingAction(true)
const hasil = await decryptToken(String(token?.current))
const response = await apiApproveRejectProjectTask({ user: hasil, id: tugas.id, action: 'reject', note })
if (response.success) {
dispatch(setUpdateProject({ ...update, progress: !update.progress, task: !update.task }))
Toast.show({ type: 'small', text1: 'Tugas berhasil ditolak' })
} else {
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
const message = error?.response?.data?.message || "Gagal menolak tugas"
Toast.show({ type: 'small', text1: message })
} finally {
setLoadingAction(false)
setModalTolak(false)
}
}
async function handleDelete() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiDeleteProjectTask({
user: hasil,
idProject: id,
}, tugas.id);
const response = await apiDeleteProjectTask({ user: hasil, idProject: id }, tugas.id);
if (response.success) {
dispatch(setUpdateProject({ ...update, progress: !update.progress, task: !update.task }))
setModal(false);
Toast.show({ type: 'small', text1: 'Berhasil menghapus data', })
Toast.show({ type: 'small', text1: 'Berhasil menghapus data' })
}
} catch (error : any ) {
console.error(error);
} catch (error: any) {
const message = error?.response?.data?.message || "Gagal menghapus data"
Toast.show({ type: 'small', text1: message })
}
}
const canTakeAction = member || isAdmin
const showAjukan = (member || isApprover) && tugas.status === 0 && status !== 3
const showApproverActions = isApprover && tugas.status === 2
return (
<>
<View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>
Tanggal & Tugas
</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<SkeletonTask key={index} />
)
})
:
data.length > 0
?
data.map((item, index) => {
return (
<ItemSectionTanggalTugas
key={index}
done={item.status === 1}
title={item.title}
dateStart={item.dateStart}
dateEnd={item.dateEnd}
onPress={() => {
if (status == 3 || (!member && (entityUser.role == "user" || entityUser.role == "coadmin"))) return
setTugas({
id: item.id,
status: item.status
})
setModal(true)
}}
/>
);
})
:
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada tugas</Text>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>Tanggal & Tugas</Text>
<View>
{loading
? arrSkeleton.map((_, index) => <SkeletonTask key={index} />)
: data.length > 0
? data.map((item, index) => (
<ItemSectionTanggalTugas
key={index}
status={item.status}
title={item.title}
dateStart={item.dateStart}
dateEnd={item.dateEnd}
files={item.files ?? []}
onPress={() => {
setTugas({ id: item.id, status: item.status })
setModal(true)
}}
/>
))
: <Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada tugas</Text>
}
</View>
</View>
<DrawerBottom
animation="slide"
isVisible={isModal}
setVisible={setModal}
title="Menu"
>
<View style={Styles.rowItemsCenter}>
{/* Drawer menu */}
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu" height={40}>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{/* Baris 1 — selalu tampil */}
<MenuItemRow
icon={
<MaterialCommunityIcons
name="list-status"
color={colors.text}
size={25}
/>
}
title="Update Status"
icon={<MaterialCommunityIcons name="file-multiple-outline" color={colors.text} size={25} />}
title="File Tugas"
onPress={() => {
setModal(false);
setTimeout(() => {
setSelect(true);
}, 600)
setModal(false)
router.push(`/project/${id}/tugas-file/${tugas.id}?member=${member}`)
}}
/>
<MenuItemRow
icon={
<MaterialCommunityIcons
name="pencil-outline"
color={colors.text}
size={25}
/>
}
title="Edit Tugas"
icon={<MaterialCommunityIcons name="clock-time-three-outline" color={colors.text} size={25} />}
title="Detail Waktu"
onPress={() => {
setModal(false);
router.push(`/project/update/${tugas.id}`);
setModal(false)
setTimeout(() => setModalDetail(true), 600)
}}
/>
<MenuItemRow
icon={<MaterialCommunityIcons name="history" color={colors.text} size={25} />}
title="Riwayat"
onPress={() => {
setModal(false)
handleLoadRiwayat()
setTimeout(() => setModalRiwayat(true), 600)
}}
/>
<MenuItemRow
icon={
<MaterialCommunityIcons
name="clock-time-three-outline"
color={colors.text}
size={25}
{/* Separator antar baris */}
{(showAjukan || showApproverActions || (canTakeAction && isAdmin && status !== 3)) && (
<View style={{ width: '100%', height: 15 }} />
)}
{/* Baris 2 — semua aksi kondisional dalam satu baris */}
{showAjukan && (
<MenuItemRow
icon={<MaterialCommunityIcons name="check-circle-outline" color={colors.text} size={25} />}
title="Ajukan Selesai"
disabled={loadingAction}
onPress={() => {
setModal(false)
setTimeout(() => handleSubmitAjukan(), 600)
}}
/>
)}
{showApproverActions && (
<MenuItemRow
icon={<MaterialCommunityIcons name="shield-check-outline" color={colors.text} size={25} />}
title="Persetujuan"
disabled={loadingAction}
onPress={() => {
setModal(false)
setTimeout(() => setModalPersetujuan(true), 600)
}}
/>
)}
{canTakeAction && isAdmin && status !== 3 && (
<>
<MenuItemRow
icon={<MaterialCommunityIcons name="pencil-outline" color={colors.text} size={25} />}
title="Edit Tugas"
onPress={() => {
setModal(false)
router.push(`/project/update/${tugas.id}`)
}}
/>
}
title="Detail Waktu"
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus Tugas"
onPress={() => {
setModal(false)
setTimeout(() => setShowDeleteModal(true), 600)
}}
/>
</>
)}
</View>
</DrawerBottom>
{/* Drawer persetujuan */}
<DrawerBottom animation="slide" isVisible={modalPersetujuan} setVisible={setModalPersetujuan} title="Persetujuan Tugas" height={25}>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
<MenuItemRow
icon={<MaterialCommunityIcons name="check-circle" color={colors.success} size={25} />}
title="Setujui"
color={colors.success}
disabled={loadingAction}
onPress={() => {
setModal(false);
setTimeout(() => {
setModalDetail(true)
}, 600)
setModalPersetujuan(false)
setTimeout(() => setModalKonfirmasiSetujui(true), 600)
}}
/>
</View>
<View style={[Styles.rowItemsCenter, Styles.mt15]}>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus Tugas"
icon={<MaterialCommunityIcons name="close-circle-outline" color={colors.error} size={25} />}
title="Tolak"
color={colors.error}
disabled={loadingAction}
onPress={() => {
setModal(false)
setTimeout(() => {
setShowDeleteModal(true)
}, 600)
setModalPersetujuan(false)
setTimeout(() => setModalTolak(true), 600)
}}
/>
</View>
@@ -234,24 +311,34 @@ export default function SectionTanggalTugasProject({ status, member, refreshing
visible={showDeleteModal}
title="Konfirmasi"
message="Apakah anda yakin ingin menghapus data ini?"
onConfirm={() => {
setShowDeleteModal(false)
handleDelete()
}}
onConfirm={() => { setShowDeleteModal(false); handleDelete() }}
onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus"
cancelText="Batal"
/>
<ModalSelect
category="status-task"
close={() => { setSelect(false) }}
onSelect={(value) => {
handleUpdate(Number(value.val))
}}
title="Status"
open={isSelect}
valChoose={String(tugas.status)}
<ModalConfirmation
visible={modalKonfirmasiSetujui}
title="Konfirmasi"
message="Apakah anda yakin ingin menyetujui tugas ini?"
onConfirm={handleSetujui}
onCancel={() => setModalKonfirmasiSetujui(false)}
confirmText="Setujui"
cancelText="Batal"
/>
<ModalRiwayatApproval
isVisible={modalRiwayat}
setVisible={setModalRiwayat}
data={riwayatData}
loading={loadingRiwayat}
/>
<ModalTolakApproval
isVisible={modalTolak}
setVisible={setModalTolak}
onTolak={handleTolak}
loading={loadingAction}
/>
<ModalListDetailTugasProject

View File

@@ -1,12 +1,11 @@
import { ColorsStatus } from "@/constants/ColorsStatus";
import Styles from "@/constants/Styles";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign } from "@expo/vector-icons";
import Text from "./Text";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { View } from "react-native";
import Text from "./Text";
type Props = {
text?: string,
text?: string
title?: string
}
@@ -14,18 +13,26 @@ export default function SectionCancel({ text, title }: Props) {
const { colors } = useTheme();
return (
<View style={[Styles.p10, Styles.round05, Styles.mb15, { backgroundColor: colors.error + '70' }]}>
<View style={[Styles.rowItemsCenter]}>
<AntDesign name="warning" size={22} style={[Styles.mr10]} color={colors.text} />
<Text style={[Styles.textDefaultSemiBold]}>{title ? title : 'Kegiatan Dibatalkan'}</Text>
<View style={[
Styles.wrapPaper,
Styles.mb15,
Styles.sectionCard,
{ backgroundColor: colors.error + '12', borderColor: colors.error + '40' },
]}>
<View style={[Styles.sectionHeader, !text && { marginBottom: 0 }]}>
<View style={[Styles.sectionIconBox, Styles.mr10, { backgroundColor: colors.error + '20' }]}>
<MaterialCommunityIcons name="close-circle-outline" size={18} color={colors.error} />
</View>
<Text style={[Styles.textDefaultSemiBold, { color: colors.error }]}>
{title ?? 'Kegiatan Dibatalkan'}
</Text>
</View>
{
text && (
<View>
<Text style={[Styles.mt05]}>{text}</Text>
</View>
)
}
{text && (
<View style={[Styles.reportContent, { borderLeftColor: colors.error + '50' }]}>
<Text style={[Styles.textDefault, { color: colors.text }]}>{text}</Text>
</View>
)}
</View>
)
}
}

View File

@@ -1,27 +1,87 @@
import Styles from "@/constants/Styles";
import { useTheme } from "@/providers/ThemeProvider";
import { AntDesign } from "@expo/vector-icons";
import { View } from "react-native";
import ProgressBar from "./progressBar";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useEffect, useRef } from "react";
import { Animated, View } from "react-native";
import Text from "./Text";
type Props = {
text: string,
progress: number
doneCount?: number
totalCount?: number
}
export default function SectionProgress({ text, progress }: Props) {
export default function SectionProgress({ progress, doneCount, totalCount }: Props) {
const { colors } = useTheme();
const animatedWidth = useRef(new Animated.Value(0)).current;
const progressColor = colors.tabActive;
const statusLabel = progress === 100
? 'Selesai'
: progress > 0
? 'Sedang berlangsung'
: 'Belum ada tugas yang diselesaikan';
useEffect(() => {
animatedWidth.setValue(0);
Animated.timing(animatedWidth, {
toValue: progress,
duration: 900,
useNativeDriver: false,
}).start();
}, [progress]);
return (
<View style={[Styles.wrapPaper, Styles.rowItemsCenter, { backgroundColor: colors.card }]}>
<View style={[Styles.iconContent]}>
<AntDesign name="areachart" size={30} color={'black'} />
<View style={[
Styles.wrapPaper,
Styles.mb15,
Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: progressColor + '30' },
]}>
<View style={Styles.sectionHeaderRow}>
<View style={Styles.flex1}>
<View style={Styles.rowItemsCenter}>
<View style={[Styles.sectionIconBox, Styles.mr10, { backgroundColor: progressColor + '22' }]}>
<MaterialCommunityIcons name="chart-line" size={18} color={progressColor} />
</View>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>
Kemajuan Kegiatan
</Text>
</View>
<Text style={[Styles.textMediumNormal, { color: colors.dimmed, marginLeft: 42 }]}>
{statusLabel}
</Text>
</View>
<View style={Styles.badgeCol}>
<View style={[Styles.progressBadge, { backgroundColor: progressColor + '18', borderColor: progressColor + '45' }]}>
<Text style={[Styles.textProgressPercent, { color: progressColor }]}>
{progress}%
</Text>
</View>
{totalCount !== undefined && doneCount !== undefined && (
<View style={[Styles.taskCountBadge, { backgroundColor: progressColor + '18' }]}>
<Text style={[Styles.textSmallSemiBold, { color: progressColor }]}>
{doneCount}/{totalCount} tugas
</Text>
</View>
)}
</View>
</View>
<View style={[Styles.ml10, { flex: 1 }]}>
<Text style={[Styles.mb05]}>{text}</Text>
<ProgressBar margin={0} category="page" value={progress} />
<View style={[Styles.progressTrack, { backgroundColor: colors.icon + '20' }]}>
<Animated.View style={[
Styles.progressFill,
{
backgroundColor: progressColor,
width: animatedWidth.interpolate({
inputRange: [0, 100],
outputRange: ['0%', '100%'],
}),
},
]} />
</View>
</View>
)
}
);
}

View File

@@ -10,15 +10,13 @@ import { startActivityAsync } from 'expo-intent-launcher';
import { useLocalSearchParams } from "expo-router";
import * as Sharing from 'expo-sharing';
import { useEffect, useState } from "react";
import { Alert, Platform, View } from "react-native";
import { Alert, Platform, Pressable, View } from "react-native";
import * as mime from 'react-native-mime-types';
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
import ModalConfirmation from "../ModalConfirmation";
import ButtonMenuHeader from "../buttonMenuHeader";
import BorderBottomItem from "../borderBottomItem";
import DrawerBottom from "../drawerBottom";
import MenuItemRow from "../menuItemRow";
import ModalConfirmation from "../ModalConfirmation";
import ModalLoading from "../modalLoading";
import Skeleton from "../skeleton";
import Text from "../Text";
@@ -30,6 +28,28 @@ type Props = {
idStorage: string
}
function getFileIcon(extension: string): keyof typeof MaterialCommunityIcons.glyphMap {
const ext = extension.toLowerCase()
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return 'image-outline'
if (ext === 'pdf') return 'file-pdf-box'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return 'video-outline'
if (['doc', 'docx'].includes(ext)) return 'file-word-outline'
if (['xls', 'xlsx'].includes(ext)) return 'file-excel-outline'
if (['zip', 'rar', '7z'].includes(ext)) return 'zip-box-outline'
return 'file-outline'
}
function getFileColor(extension: string): string {
const ext = extension.toLowerCase()
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif'].includes(ext)) return '#339AF0'
if (ext === 'pdf') return '#F03E3E'
if (['mp4', 'mov', 'avi', 'mkv'].includes(ext)) return '#AE3EC9'
if (['doc', 'docx'].includes(ext)) return '#1C7ED6'
if (['xls', 'xlsx'].includes(ext)) return '#2F9E44'
if (['zip', 'rar', '7z'].includes(ext)) return '#E8590C'
return '#868E96'
}
export default function SectionFileTask({ refreshing, isMemberDivision }: { refreshing: boolean, isMemberDivision: boolean }) {
const { colors } = useTheme()
const [isModal, setModal] = useState(false)
@@ -119,7 +139,7 @@ export default function SectionFileTask({ refreshing, isMemberDivision }: { refr
} else {
Toast.show({ type: 'small', text1: response.message, })
}
} catch (error : any ) {
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menghapus file"
@@ -134,32 +154,39 @@ export default function SectionFileTask({ refreshing, isMemberDivision }: { refr
<ModalLoading isVisible={loadingOpen} setVisible={setLoadingOpen} />
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>File</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<Skeleton key={index} width={100} height={40} widthType="percent" borderRadius={10} />
)
})
:
data.length > 0 ?
data.map((item, index) => {
return (
<BorderBottomItem
key={index}
borderType="all"
icon={<MaterialCommunityIcons name="file-outline" size={25} color={colors.text} />}
title={item.name + '.' + item.extension}
titleWeight="normal"
onPress={() => { setSelectFile(item); setModal(true) }}
/>
)
})
:
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada file</Text>
}
</View>
{loading ? (
<View style={Styles.fileGrid}>
{arrSkeleton.map((_, index) => (
<Skeleton key={index} width={48} height={68} widthType="percent" borderRadius={10} />
))}
</View>
) : data.length > 0 ? (
<View style={Styles.fileGrid}>
{data.map((item, index) => {
const iconName = getFileIcon(item.extension)
const iconColor = getFileColor(item.extension)
return (
<Pressable
key={index}
onPress={() => { setSelectFile(item); setModal(true) }}
style={[Styles.fileCard, { backgroundColor: colors.card, borderColor: colors.icon + '18' }]}
>
<View style={[Styles.sectionIconBox, { backgroundColor: iconColor + '20' }]}>
<MaterialCommunityIcons name={iconName} size={18} color={iconColor} />
</View>
<View style={Styles.flex1}>
<Text style={Styles.textDefault} numberOfLines={1}>{item.name}</Text>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]}>
{item.extension.toUpperCase()}
</Text>
</View>
</Pressable>
)
})}
</View>
) : (
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada file</Text>
)}
</View>
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu">

View File

@@ -1,20 +1,19 @@
import Styles from "@/constants/Styles";
import { apiDeleteLinkTask, apiGetTaskOne } from "@/lib/api";
import { urlCompleted } from "@/lib/fun_urlCompleted";
import { setUpdateTask } from "@/lib/taskUpdate";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { Linking, View } from "react-native";
import { View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
import ModalConfirmation from "../ModalConfirmation";
import BorderBottomItem from "../borderBottomItem";
import DrawerBottom from "../drawerBottom";
import MenuItemRow from "../menuItemRow";
import Text from "../Text";
import ItemSectionLink from "../project/itemSectionLink";
type Props = {
id: string
@@ -62,84 +61,60 @@ export default function SectionLinkTask({ refreshing, isMemberDivision }: { refr
} else {
Toast.show({ type: 'small', text1: response.message, })
}
} catch (error : any ) {
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menghapus link"
Toast.show({ type: 'small', text1: message })
} finally {
setModal(false)
}
}
if (data.length === 0) return null;
const canDelete = (entityUser.role !== "user" && entityUser.role !== "coadmin") || isMemberDivision;
return (
<>
{
data.length > 0 &&
<>
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>Link</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
data.map((item, index) => {
return (
<BorderBottomItem
key={index}
borderType="all"
icon={<Feather name="link" size={25} color={colors.text} />}
title={item.link}
titleWeight="normal"
onPress={() => { setSelectLink(item); setModal(true) }}
/>
)
})
}
</View>
</View>
<View style={[Styles.mb15]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>Link</Text>
<View>
{data.map((item, index) => (
<ItemSectionLink
key={index}
link={item.link}
canDelete={canDelete}
onLongPress={() => { setSelectLink(item); setModal(true) }}
/>
))}
</View>
</View>
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu">
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<Feather name="external-link" color={colors.text} size={25} />}
title="Buka Link"
onPress={() => {
Linking.openURL(urlCompleted(String(selectLink?.link)))
}}
/>
{
(entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision
?
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus"
onPress={() => {
setModal(false)
setTimeout(() => {
setShowDeleteModal(true)
}, 600)
}}
/>
:
<></>
}
</View>
</DrawerBottom>
<ModalConfirmation
visible={showDeleteModal}
title="Konfirmasi"
message="Apakah Anda yakin ingin menghapus link ini? Link yang dihapus tidak dapat dikembalikan"
onConfirm={() => {
setShowDeleteModal(false)
handleDelete()
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu">
<View style={Styles.rowItemsCenter}>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus Link"
onPress={() => {
setModal(false)
setTimeout(() => setShowDeleteModal(true), 600)
}}
onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus"
cancelText="Batal"
/>
</>
}
</View>
</DrawerBottom>
<ModalConfirmation
visible={showDeleteModal}
title="Konfirmasi"
message="Apakah Anda yakin ingin menghapus link ini? Link yang dihapus tidak dapat dikembalikan"
onConfirm={() => {
setShowDeleteModal(false)
handleDelete()
}}
onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus"
cancelText="Batal"
/>
</>
)
}
}

View File

@@ -7,15 +7,14 @@ import { useTheme } from "@/providers/ThemeProvider";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { View } from "react-native";
import { Pressable, View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
import ModalConfirmation from "../ModalConfirmation";
import BorderBottomItem from "../borderBottomItem";
import DrawerBottom from "../drawerBottom";
import ImageUser from "../imageNew";
import MenuItemRow from "../menuItemRow";
import SkeletonTwoItem from "../skeletonTwoItem";
import ModalConfirmation from "../ModalConfirmation";
import Skeleton from "../skeleton";
import Text from "../Text";
type Props = {
@@ -88,7 +87,7 @@ export default function SectionMemberTask({ refreshing, isAdminDivision }: { ref
} else {
Toast.show({ type: 'small', text1: response.message, })
}
} catch (error : any ) {
} catch (error: any) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menghapus anggota"
@@ -103,28 +102,46 @@ export default function SectionMemberTask({ refreshing, isAdminDivision }: { ref
<View style={[Styles.mb15]}>
<View style={[Styles.rowSpaceBetween, Styles.mv05]}>
<Text style={[Styles.textDefaultSemiBold]}>Anggota</Text>
<Text style={[Styles.textDefault]}>Total {data.length} Anggota</Text>
{!loading && data.length > 0 && (
<Text style={[Styles.textDefault, { color: colors.dimmed }]}>{data.length} orang</Text>
)}
</View>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
<View style={{ gap: 6 }}>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<SkeletonTwoItem key={index} />
<View key={index} style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Skeleton width={40} height={40} borderRadius={20} />
<View style={{ flex: 1, gap: 5 }}>
<Skeleton width={60} height={12} widthType="percent" borderRadius={6} />
<Skeleton width={35} height={10} widthType="percent" borderRadius={6} />
</View>
</View>
)
})
:
data.length > 0 ? (
data.map((item, index) => {
return (
<BorderBottomItem
// <BorderBottomItem
// key={index}
// borderType="bottom"
// icon={
// <ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} />
// }
// title={item.name}
// onPress={() => {
// setMemberChoose({
// id: item.idUser,
// name: item.name,
// });
// setModal(true);
// }}
// />
<Pressable
key={index}
borderType="bottom"
icon={
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} />
}
title={item.name}
onPress={() => {
setMemberChoose({
id: item.idUser,
@@ -132,7 +149,33 @@ export default function SectionMemberTask({ refreshing, isAdminDivision }: { ref
});
setModal(true);
}}
/>
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.card,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.icon + '18',
paddingHorizontal: 12,
paddingVertical: 10,
gap: 12,
}}
>
<ImageUser src={`${ConstEnv.url_storage}/files/${item.img}`} size="xs" />
<View style={{ flex: 1 }}>
<Text style={[Styles.textDefault]} numberOfLines={1}>{item.name}</Text>
</View>
<View style={{
backgroundColor: colors.dimmed + '15',
borderRadius: 20,
paddingHorizontal: 8,
paddingVertical: 3,
}}>
<Text style={[Styles.textSmallSemiBold, { color: colors.dimmed }]} numberOfLines={1}>
{item.position}
</Text>
</View>
</Pressable>
);
})
) : (

View File

@@ -2,6 +2,7 @@ import Styles from "@/constants/Styles";
import { apiGetTaskOne } from "@/lib/api";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { View } from "react-native";
@@ -9,7 +10,6 @@ import { useSelector } from "react-redux";
import Text from "../Text";
import TextExpandable from "../textExpandable";
export default function SectionReportTask({ refreshing }: { refreshing: boolean }) {
const { colors } = useTheme()
const update = useSelector((state: any) => state.taskUpdate)
@@ -27,29 +27,30 @@ export default function SectionReportTask({ refreshing }: { refreshing: boolean
}
}
useEffect(() => {
handleLoad()
}, [update.report])
useEffect(() => {
if (refreshing)
handleLoad();
}, [refreshing]);
useEffect(() => { handleLoad() }, [update.report])
useEffect(() => { if (refreshing) handleLoad() }, [refreshing])
if (!data || data === "") return null;
return (
<>
{
data != "" && data != null &&
<View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>
Laporan Kegiatan
</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
<TextExpandable content={data} maxLines={2} />
</View>
<View style={[
Styles.wrapPaper,
Styles.mb15,
Styles.sectionCard,
{ backgroundColor: colors.card, borderColor: colors.icon + '18' },
]}>
<View style={Styles.sectionHeader}>
<View style={[Styles.sectionIconBox, Styles.mr10, { backgroundColor: colors.tabActive + '18' }]}>
<MaterialCommunityIcons name="text-box-outline" size={18} color={colors.tabActive} />
</View>
}
</>
<Text style={[Styles.textDefaultSemiBold, { color: colors.text }]}>
Laporan Kegiatan
</Text>
</View>
<View style={[Styles.reportContent, { borderLeftColor: colors.tabActive + '50' }]}>
<TextExpandable content={data} maxLines={2} />
</View>
</View>
)
}
}

View File

@@ -1,5 +1,5 @@
import Styles from "@/constants/Styles";
import { apiDeleteTaskTugas, apiGetTaskOne, apiUpdateStatusTaskDivision } from "@/lib/api";
import { apiApproveRejectTaskTugas, apiDeleteTaskTugas, apiGetTaskOne, apiGetTaskTugasApprovals, apiSubmitTaskTugas } from "@/lib/api";
import { setUpdateTask } from "@/lib/taskUpdate";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
@@ -9,11 +9,12 @@ import { useEffect, useState } from "react";
import { View } from "react-native";
import Toast from "react-native-toast-message";
import { useDispatch, useSelector } from "react-redux";
import ModalConfirmation from "../ModalConfirmation";
import DrawerBottom from "../drawerBottom";
import ItemSectionTanggalTugas from "../itemSectionTanggalTugas";
import MenuItemRow from "../menuItemRow";
import ModalSelect from "../modalSelect";
import ModalConfirmation from "../ModalConfirmation";
import ModalRiwayatApproval from "../ModalRiwayatApproval";
import ModalTolakApproval from "../ModalTolakApproval";
import SkeletonTask from "../skeletonTask";
import Text from "../Text";
import ModalListDetailTugasTask from "./modalListDetailTugasTask";
@@ -25,27 +26,44 @@ type Props = {
status: number;
dateStart: string;
dateEnd: string;
files?: { name: string; extension: string }[];
}
export default function SectionTanggalTugasTask({ refreshing, isMemberDivision }: { refreshing: boolean, isMemberDivision: boolean }) {
type ApprovalRecord = {
id: string
status: number
note?: string
submitter: { name: string }
approver?: { name: string }
createdAt: string
}
export default function SectionTanggalTugasTask({ refreshing, isMemberDivision, isAdminDivision, status, idGroup }: { refreshing: boolean, isMemberDivision: boolean, isAdminDivision: boolean, status?: number, idGroup: string }) {
const { colors } = useTheme()
const dispatch = useDispatch()
const entityUser = useSelector((state: any) => state.user);
const update = useSelector((state: any) => state.taskUpdate)
const [isModal, setModal] = useState(false)
const [isSelect, setSelect] = useState(false)
const { token, decryptToken } = useAuthSession()
const [loading, setLoading] = useState(true)
const [loadingAction, setLoadingAction] = useState(false)
const [loadingRiwayat, setLoadingRiwayat] = useState(false)
const arrSkeleton = Array.from({ length: 5 })
const [modalDetail, setModalDetail] = useState(false)
const [modalRiwayat, setModalRiwayat] = useState(false)
const [modalTolak, setModalTolak] = useState(false)
const [modalKonfirmasiSetujui, setModalKonfirmasiSetujui] = useState(false)
const [modalPersetujuan, setModalPersetujuan] = useState(false)
const [riwayatData, setRiwayatData] = useState<ApprovalRecord[]>([])
const { id, detail } = useLocalSearchParams<{ id: string, detail: string }>();
const [data, setData] = useState<Props[]>([])
const [tugas, setTugas] = useState({
id: '',
status: 0,
})
const [tugas, setTugas] = useState({ id: '', status: 0 })
const [showDeleteModal, setShowDeleteModal] = useState(false)
const isApprover = (entityUser.isApprover && entityUser.idGroup === idGroup) || ['supadmin', 'developer'].includes(entityUser.role)
const isAdmin = entityUser.role !== 'user' && entityUser.role !== 'coadmin'
const canTakeAction = isMemberDivision || isAdmin
async function handleLoad(loading: boolean) {
try {
setLoading(loading)
@@ -59,194 +77,266 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision }
}
}
async function handleUpdate(status: number) {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiUpdateStatusTaskDivision({
user: hasil,
idProject: detail,
status: status,
}, tugas.id);
if (response.success) {
dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task }))
Toast.show({ type: 'small', text1: 'Berhasil mengubah data', })
} else {
Toast.show({ type: 'small', text1: response.message, })
}
} catch (error : any ) {
console.error(error);
const message = error?.response?.data?.message || "Gagal mengubah data"
useEffect(() => { handleLoad(false) }, [update.task])
useEffect(() => { if (refreshing) handleLoad(false) }, [refreshing])
useEffect(() => { handleLoad(true) }, [])
Toast.show({ type: 'small', text1: message })
async function handleLoadRiwayat() {
try {
setLoadingRiwayat(true)
const hasil = await decryptToken(String(token?.current))
const response = await apiGetTaskTugasApprovals({ user: hasil, id: tugas.id })
setRiwayatData(response.data ?? [])
} catch (error) {
console.error(error)
} finally {
setSelect(false)
setLoadingRiwayat(false)
}
}
useEffect(() => {
handleLoad(false)
}, [update.task])
async function handleSubmitAjukan() {
try {
setLoadingAction(true)
const hasil = await decryptToken(String(token?.current))
const response = await apiSubmitTaskTugas({ user: hasil, id: tugas.id })
if (response.success) {
dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task }))
Toast.show({ type: 'small', text1: 'Berhasil mengajukan task untuk persetujuan' })
} else {
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal mengajukan persetujuan" })
} finally {
setLoadingAction(false)
setModal(false)
}
}
useEffect(() => {
if (refreshing)
handleLoad(false);
}, [refreshing]);
useEffect(() => {
handleLoad(true)
}, [])
async function handleSetujui() {
try {
setLoadingAction(true)
const hasil = await decryptToken(String(token?.current))
const response = await apiApproveRejectTaskTugas({ user: hasil, id: tugas.id, action: 'approve' })
if (response.success) {
dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task }))
Toast.show({ type: 'small', text1: 'Tugas berhasil disetujui' })
} else {
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menyetujui tugas" })
} finally {
setLoadingAction(false)
setModalKonfirmasiSetujui(false)
}
}
async function handleTolak(note: string) {
try {
setLoadingAction(true)
const hasil = await decryptToken(String(token?.current))
const response = await apiApproveRejectTaskTugas({ user: hasil, id: tugas.id, action: 'reject', note })
if (response.success) {
dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task }))
Toast.show({ type: 'small', text1: 'Tugas berhasil ditolak' })
} else {
Toast.show({ type: 'small', text1: response.message })
}
} catch (error: any) {
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menolak tugas" })
} finally {
setLoadingAction(false)
setModalTolak(false)
}
}
async function handleDelete() {
try {
const hasil = await decryptToken(String(token?.current));
const response = await apiDeleteTaskTugas({
user: hasil,
idProject: detail,
}, tugas.id);
const response = await apiDeleteTaskTugas({ user: hasil, idProject: detail }, tugas.id);
if (response.success) {
dispatch(setUpdateTask({ ...update, progress: !update.progress, task: !update.task }))
Toast.show({ type: 'small', text1: 'Berhasil menghapus data', })
Toast.show({ type: 'small', text1: 'Berhasil menghapus data' })
} else {
Toast.show({ type: 'small', text1: response.message, })
Toast.show({ type: 'small', text1: response.message })
}
} catch (error : any ) {
console.error(error);
const message = error?.response?.data?.message || "Gagal menghapus data"
Toast.show({ type: 'small', text1: message })
} finally {
setModal(false);
} catch (error: any) {
Toast.show({ type: 'small', text1: error?.response?.data?.message || "Gagal menghapus data" })
}
}
const canApprove = isApprover || isAdminDivision
const showAjukan = (isMemberDivision || canApprove) && tugas.status === 0 && status !== 3
const showApproverActions = canApprove && tugas.status === 2
return (
<>
<View style={[Styles.mb15, Styles.mt10]}>
<Text style={[Styles.textDefaultSemiBold, Styles.mv05]}>Tanggal & Tugas</Text>
<View style={[Styles.wrapPaper, { backgroundColor: colors.card, borderColor: colors.background }]}>
{
loading ?
arrSkeleton.map((item, index) => {
return (
<SkeletonTask key={index} />
)
})
:
data.length > 0
?
data.map((item, index) => {
return (
<ItemSectionTanggalTugas
key={index}
done={item.status === 1}
title={item.title}
dateStart={item.dateStart}
dateEnd={item.dateEnd}
onPress={() => {
setTugas({
id: item.id,
status: item.status
})
setModal(true)
}}
/>
);
})
:
<Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada tugas</Text>
<View>
{loading
? arrSkeleton.map((_, index) => <SkeletonTask key={index} />)
: data.length > 0
? data.map((item, index) => (
<ItemSectionTanggalTugas
key={index}
status={item.status}
title={item.title}
dateStart={item.dateStart}
dateEnd={item.dateEnd}
files={item.files ?? []}
onPress={() => {
setTugas({ id: item.id, status: item.status })
setModal(true)
}}
/>
))
: <Text style={[Styles.textDefault, { textAlign: 'center', color: colors.dimmed }]}>Tidak ada tugas</Text>
}
</View>
</View>
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu">
<View style={Styles.rowItemsCenter}>
{/* Drawer menu */}
<DrawerBottom animation="slide" isVisible={isModal} setVisible={setModal} title="Menu" height={40}>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{/* Baris 1 — selalu tampil */}
<MenuItemRow
icon={
<MaterialCommunityIcons
name="clock-time-three-outline"
color={colors.text}
size={25}
/>
}
title="Detail Waktu"
icon={<MaterialCommunityIcons name="file-multiple-outline" color={colors.text} size={25} />}
title="File Tugas"
onPress={() => {
setModal(false);
setTimeout(() => {
setModalDetail(true)
}, 600)
setModal(false)
router.push(`/division/${id}/task/${detail}/tugas-file/${tugas.id}?member=${isMemberDivision}`)
}}
/>
<MenuItemRow
icon={<MaterialCommunityIcons name="clock-time-three-outline" color={colors.text} size={25} />}
title="Detail Waktu"
onPress={() => {
setModal(false)
setTimeout(() => setModalDetail(true), 600)
}}
/>
<MenuItemRow
icon={<MaterialCommunityIcons name="history" color={colors.text} size={25} />}
title="Riwayat"
onPress={() => {
setModal(false)
handleLoadRiwayat()
setTimeout(() => setModalRiwayat(true), 600)
}}
/>
{
(entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision
?
<>
<MenuItemRow
icon={<MaterialCommunityIcons name="list-status" color={colors.text} size={25} />}
title="Update Status"
onPress={() => {
setModal(false)
setTimeout(() => {
setSelect(true)
}, 600);
}}
/>
<MenuItemRow
icon={<MaterialCommunityIcons name="pencil-outline" color={colors.text} size={25} />}
title="Edit Tugas"
onPress={() => {
setModal(false)
router.push(`./update/${tugas.id}`)
}}
/>
</>
:
<></>
}
</View>
{
(entityUser.role != "user" && entityUser.role != "coadmin") || isMemberDivision
?
<View style={[Styles.rowItemsCenter, Styles.mt15]}>
{/* Separator */}
{(showAjukan || showApproverActions || (canTakeAction && isAdmin && status !== 3)) && (
<View style={{ width: '100%', height: 15 }} />
)}
{/* Baris 2 — aksi kondisional */}
{showAjukan && (
<MenuItemRow
icon={<MaterialCommunityIcons name="check-circle-outline" color={colors.text} size={25} />}
title="Ajukan Selesai"
disabled={loadingAction}
onPress={() => {
setModal(false)
setTimeout(() => handleSubmitAjukan(), 600)
}}
/>
)}
{showApproverActions && (
<MenuItemRow
icon={<MaterialCommunityIcons name="shield-check-outline" color={colors.text} size={25} />}
title="Persetujuan"
disabled={loadingAction}
onPress={() => {
setModal(false)
setTimeout(() => setModalPersetujuan(true), 600)
}}
/>
)}
{canTakeAction && isAdmin && status !== 3 && (
<>
<MenuItemRow
icon={<MaterialCommunityIcons name="pencil-outline" color={colors.text} size={25} />}
title="Edit Tugas"
onPress={() => {
setModal(false)
router.push(`./update/${tugas.id}`)
}}
/>
<MenuItemRow
icon={<Ionicons name="trash-outline" color={colors.text} size={25} />}
title="Hapus Tugas"
onPress={() => {
setModal(false)
setTimeout(() => {
setShowDeleteModal(true)
}, 600)
setTimeout(() => setShowDeleteModal(true), 600)
}}
/>
</View>
:
<></>
}
</>
)}
</View>
</DrawerBottom>
{/* Drawer persetujuan */}
<DrawerBottom animation="slide" isVisible={modalPersetujuan} setVisible={setModalPersetujuan} title="Persetujuan Tugas" height={25}>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
<MenuItemRow
icon={<MaterialCommunityIcons name="check-circle" color={colors.success} size={25} />}
title="Setujui"
color={colors.success}
disabled={loadingAction}
onPress={() => {
setModalPersetujuan(false)
setTimeout(() => setModalKonfirmasiSetujui(true), 600)
}}
/>
<MenuItemRow
icon={<MaterialCommunityIcons name="close-circle-outline" color={colors.error} size={25} />}
title="Tolak"
color={colors.error}
disabled={loadingAction}
onPress={() => {
setModalPersetujuan(false)
setTimeout(() => setModalTolak(true), 600)
}}
/>
</View>
</DrawerBottom>
<ModalConfirmation
visible={showDeleteModal}
title="Konfirmasi"
message="Apakah anda yakin ingin menghapus data ini?"
onConfirm={() => {
setShowDeleteModal(false)
handleDelete()
}}
onConfirm={() => { setShowDeleteModal(false); handleDelete() }}
onCancel={() => setShowDeleteModal(false)}
confirmText="Hapus"
cancelText="Batal"
/>
<ModalSelect
category="status-task"
close={() => setSelect(false)}
onSelect={(value) => { handleUpdate(Number(value.val)) }}
title="Status"
open={isSelect}
valChoose={String(tugas.status)}
<ModalConfirmation
visible={modalKonfirmasiSetujui}
title="Konfirmasi"
message="Apakah anda yakin ingin menyetujui tugas ini?"
onConfirm={handleSetujui}
onCancel={() => setModalKonfirmasiSetujui(false)}
confirmText="Setujui"
cancelText="Batal"
/>
<ModalRiwayatApproval
isVisible={modalRiwayat}
setVisible={setModalRiwayat}
data={riwayatData}
loading={loadingRiwayat}
/>
<ModalTolakApproval
isVisible={modalTolak}
setVisible={setModalTolak}
onTolak={handleTolak}
loading={loadingAction}
/>
<ModalListDetailTugasTask
@@ -256,4 +346,4 @@ export default function SectionTanggalTugasTask({ refreshing, isMemberDivision }
/>
</>
)
}
}

View File

@@ -1,32 +1,38 @@
import Styles from "@/constants/Styles";
import { useTheme } from "@/providers/ThemeProvider";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useRef, useState, useEffect } from "react";
import { Animated, Pressable, View } from "react-native";
import Text from "./Text";
export default function TextExpandable({ content, maxLines }: { content: string, maxLines: number }) {
const { colors } = useTheme();
const [isExpanded, setIsExpanded] = useState(false);
const [shouldShowMore, setShouldShowMore] = useState(false);
const [collapsedHeight, setCollapsedHeight] = useState(0);
const [fullHeight, setFullHeight] = useState(0);
const animatedHeight = useRef(new Animated.Value(0)).current;
useEffect(() => {
setCollapsedHeight(0);
setFullHeight(0);
setShouldShowMore(false);
setIsExpanded(false);
}, [content]);
const measureCollapsed = (e: any) => {
if (collapsedHeight === 0) {
setCollapsedHeight(e.nativeEvent.layout.height);
animatedHeight.setValue(e.nativeEvent.layout.height);
}
const h = e.nativeEvent.layout.height;
setCollapsedHeight(h);
animatedHeight.setValue(h);
};
const measureFull = (e: any) => {
if (fullHeight === 0) {
setFullHeight(e.nativeEvent.layout.height);
}
setFullHeight(e.nativeEvent.layout.height);
};
// Cek apakah memang perlu "View More"
useEffect(() => {
if (collapsedHeight > 0 && fullHeight > 0) {
setShouldShowMore(fullHeight > collapsedHeight + 1); // +1 untuk toleransi float
setShouldShowMore(fullHeight > collapsedHeight + 1);
}
}, [collapsedHeight, fullHeight]);
@@ -41,41 +47,34 @@ export default function TextExpandable({ content, maxLines }: { content: string,
return (
<View>
{/* Hidden full text for measurement */}
<View style={Styles.hidden}>
<Text style={Styles.textDefault} onLayout={measureFull}>
{content}
</Text>
</View>
{/* Collapsed text for measurement */}
<View style={Styles.hidden}>
<Text
numberOfLines={maxLines}
style={Styles.textDefault}
onLayout={measureCollapsed}
ellipsizeMode="tail"
>
<Text numberOfLines={maxLines} style={Styles.textDefault} onLayout={measureCollapsed} ellipsizeMode="tail">
{content}
</Text>
</View>
{/* Animated visible text */}
<Animated.View style={{ height: animatedHeight, overflow: 'hidden' }}>
<Text
style={Styles.textDefault}
numberOfLines={isExpanded ? undefined : maxLines}
ellipsizeMode="tail"
>
<Text style={Styles.textDefault} numberOfLines={isExpanded ? undefined : maxLines} ellipsizeMode="tail">
{content}
</Text>
</Animated.View>
{shouldShowMore && (
<Pressable onPress={toggleExpand}>
<Text style={Styles.textLink}>
{isExpanded ? 'View Less' : 'View More'}
<Pressable onPress={toggleExpand} style={Styles.expandBtn}>
<Text style={[Styles.textMediumSemiBold, { color: colors.tabActive }]}>
{isExpanded ? 'Sembunyikan' : 'Lihat selengkapnya'}
</Text>
<MaterialCommunityIcons
name={isExpanded ? 'chevron-up' : 'chevron-down'}
size={16}
color={colors.tabActive}
/>
</Pressable>
)}
</View>

View File

@@ -13,7 +13,7 @@ export const Colors = {
tabActive: '#2563EB',
header: '#234881',
homeGradient: '#346CC4',
dimmed: '#707887ff',
dimmed: '#707887',
success: '#40C057',
warning: '#FBBF24',
error: '#F87171',

View File

@@ -1,970 +1 @@
import { StyleSheet } from "react-native";
const Styles = StyleSheet.create({
wrapLogin: {
flex: 1,
justifyContent: "flex-start",
alignItems: "stretch",
padding: 20,
},
textHeaderHome: {
fontSize: 18,
fontWeight: 'bold',
flex: 1,
color: 'white'
},
textDefault: {
fontSize: 15,
lineHeight: 24,
},
textDefaultSemiBold: {
fontSize: 15,
lineHeight: 24,
fontWeight: '600',
},
textMediumNormal: {
fontSize: 13,
lineHeight: 24,
},
textMediumSemiBold: {
fontSize: 13,
lineHeight: 24,
fontWeight: '600',
},
textSmallSemiBold: {
fontSize: 10,
fontWeight: '600',
},
textTitle: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
textSubtitle: {
fontSize: 20,
fontWeight: 'bold',
},
textSubtitle2: {
fontSize: 20,
},
textLink: {
fontSize: 14,
color: '#0a7ea4',
},
textInformation: {
fontSize: 12,
fontWeight: 'light',
},
cError: {
color: '#DB1514'
},
cGray: {
color: 'gray'
},
cWhite: {
color: 'white'
},
cBlack: {
color: 'black'
},
cDefault: {
color: '#19345E'
},
cFolder: {
color: '#f9cc40'
},
mb05: {
marginBottom: 5
},
mb10: {
marginBottom: 10
},
mb12: {
marginBottom: 12
},
mb13: {
marginBottom: 13
},
mb15: {
marginBottom: 15
},
mb20: {
marginBottom: 20
},
mb30: {
marginBottom: 30
},
mb50: {
marginBottom: 50
},
mb100: {
marginBottom: 100
},
mv05: {
marginVertical: 5
},
mv15: {
marginVertical: 15
},
mh03: {
marginHorizontal: 3
},
mh05: {
marginHorizontal: 5
},
mh10: {
marginHorizontal: 10
},
mh15: {
marginHorizontal: 15
},
mv50: {
marginVertical: 50
},
mv10: {
marginVertical: 10
},
mt02: {
marginTop: 2
},
mt05: {
marginTop: 5
},
mt10: {
marginTop: 10
},
mt15: {
marginTop: 15
},
mt30: {
marginTop: 30
},
mr05: {
marginRight: 5
},
mr10: {
marginRight: 10
},
ml05: {
marginLeft: 5
},
ml10: {
marginLeft: 10
},
ml15: {
marginLeft: 15
},
ml20: {
marginLeft: 20
},
ml25: {
marginLeft: 25
},
p0: {
padding: 0
},
pb05: {
paddingBottom: 5
},
pb07: {
paddingBottom: 7
},
pb10: {
paddingBottom: 10
},
pb50: {
paddingBottom: 50
},
pb100: {
paddingBottom: 100
},
pb13: {
paddingBottom: 13
},
pb15: {
paddingBottom: 15
},
pb20: {
paddingBottom: 20
},
ph05: {
paddingHorizontal: 5,
},
ph10: {
paddingHorizontal: 10,
},
ph15: {
paddingHorizontal: 15,
},
ph20: {
paddingHorizontal: 20,
},
pv03: {
paddingVertical: 3
},
pv05: {
paddingVertical: 5
},
pv10: {
paddingVertical: 10
},
pv15: {
paddingVertical: 15
},
pv20: {
paddingVertical: 20
},
p05: {
padding: 5
},
p15: {
padding: 15
},
p10: {
padding: 10
},
p20: {
padding: 20
},
round05: {
borderRadius: 5
},
round08: {
borderRadius: 8
},
round10: {
borderRadius: 10
},
round15: {
borderRadius: 15
},
round20: {
borderRadius: 20
},
h100: {
height: '100%'
},
w100: {
width: '100%'
},
w48: {
width: '48%'
},
w95: {
width: '95%'
},
w90: {
width: '90%'
},
w80: {
width: '80%'
},
w70: {
width: '70%'
},
w60: {
width: '60%'
},
w50: {
width: '50%'
},
w45: {
width: '45%'
},
w40: {
width: '40%'
},
w30: {
width: '30%'
},
round30: {
borderRadius: 30
},
labelStatus: {
paddingHorizontal: 15,
borderRadius: 10
},
labelStatusSmall: {
paddingHorizontal: 10,
borderRadius: 10
},
rowSpaceBetween: {
justifyContent: 'space-between',
flexDirection: 'row'
},
justifySpaceBetween: {
justifyContent: 'space-between'
},
rowSpaceBetweenReverse: {
justifyContent: 'space-between',
flexDirection: 'row-reverse'
},
rowItemsCenter: {
flexDirection: 'row',
alignItems: 'center'
},
rowOnly: {
flexDirection: 'row'
},
btnIconHeader: {
padding: 3,
},
btnFiturMenu: {
padding: 13,
borderRadius: 15,
borderWidth: 1,
},
btnRound: {
backgroundColor: '#1F3C88',
borderWidth: 0,
borderColor: '#1F3C88',
alignItems: 'center',
borderRadius: 30,
marginTop: 15,
paddingVertical: 10
},
btnTab: {
alignItems: 'center',
paddingVertical: 5,
paddingHorizontal: 15,
borderRadius: 20,
flexDirection: 'row',
justifyContent: 'center'
},
btnLainnya: {
alignSelf: 'flex-start',
backgroundColor: '#1F3C88',
paddingVertical: 5,
marginVertical: 5
},
btnDisabled: {
backgroundColor: '#d6d8f6',
},
btnMenuRow: {
width: '33%',
alignItems: 'center'
},
btnMenuRowMany: {
alignItems: 'center',
marginHorizontal: 10
},
inputRoundForm: {
borderRadius: 5,
borderColor: '#d6d8f6',
borderWidth: 1,
paddingVertical: 10,
paddingHorizontal: 15,
},
inputRoundFormLeft: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 0,
},
inputRoundFormRight: {
flexDirection: 'row-reverse',
alignItems: 'center',
paddingVertical: 0,
justifyContent: 'space-between'
},
verificationCell: {
width: 50,
height: 50,
lineHeight: 45,
fontSize: 24,
borderWidth: 1,
borderRadius: 15,
borderColor: 'gray',
textAlign: 'center',
},
verificationFocusCell: {
borderColor: '#19345E',
},
caraoselContent: {
flex: 1,
justifyContent: "center",
marginHorizontal: 15,
borderRadius: 15,
backgroundColor: '#19345E',
display: 'flex',
width: '92%',
resizeMode: 'stretch',
overflow: 'hidden',
},
wrapGridContent: {
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
borderRadius: 5,
marginBottom: 15
},
wrapGridCaraousel: {
width: '95%',
height: 200,
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
borderRadius: 5,
marginLeft: 5,
display: 'flex',
},
headerPaperGrid: {
paddingVertical: 25,
paddingHorizontal: 20,
alignItems: 'center',
borderTopStartRadius: 5,
borderTopEndRadius: 5
},
contentPaperGrid: {
height: 125,
borderBottomEndRadius: 5,
borderBottomStartRadius: 5,
paddingHorizontal: 20,
justifyContent: 'space-evenly'
},
contentPaperGrid2: {
height: 100,
borderBottomEndRadius: 5,
borderBottomStartRadius: 5,
paddingHorizontal: 20,
paddingVertical: 15,
justifyContent: 'flex-start'
},
wrapBar: {
height: 20,
backgroundColor: '#ccc',
borderRadius: 10,
margin: 0,
width: '100%'
},
contentBar: {
height: 20,
backgroundColor: '#3B82F6',
borderRadius: 10,
},
wrapPaper: {
padding: 10,
backgroundColor: 'white',
borderRadius: 5,
// borderWidth: 1,
// borderColor: '#d6d8f6',
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
},
noShadow: {
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
shadowBox: {
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
},
contentItemCenter: {
justifyContent: 'center',
alignItems: 'center'
},
itemEvent: {
padding: 10,
borderRadius: 10,
flexDirection: 'row',
alignContent: 'stretch',
marginBottom: 10
},
dividerEvent: {
width: 7,
borderRadius: 5,
marginRight: 10
},
wrapItemDiscussion: {
padding: 15,
borderRadius: 5,
// borderColor: '#d6d8f6',
borderBottomWidth: 1,
},
wrapItemBorderBottom: {
padding: 10,
// borderColor: '#d6d8f6',
borderBottomWidth: 1,
},
wrapItemBorderAll: {
padding: 10,
// borderColor: '#d6d8f6',
borderWidth: 1,
borderRadius: 5,
marginBottom: 5
},
wrapItemBorderNone: {
padding: 10,
marginBottom: 5
},
userProfileExtraSmall: {
width: 35,
height: 35,
borderRadius: 100
},
userProfileSmall: {
width: 48,
height: 48,
borderRadius: 100
},
userProfileBig: {
width: 100,
height: 100,
borderRadius: 100
},
imgListBanner: {
width: 100,
height: 50,
borderRadius: 5
},
iconContent: {
padding: 10,
borderRadius: 100,
backgroundColor: '#E5E7EB'
},
wrapHeadViewMember: {
backgroundColor: '#19345E',
paddingVertical: 30,
paddingHorizontal: 15,
alignItems: 'center',
borderBottomLeftRadius: 25,
borderBottomRightRadius: 25
},
modalBgTransparant: {
backgroundColor: 'rgba(0, 0, 0, 0.3)',
flex: 1
},
modalContent: {
width: '100%',
paddingBottom: 20,
backgroundColor: 'white',
borderTopRightRadius: 18,
borderTopLeftRadius: 18,
position: 'absolute',
bottom: 0,
},
titleContainer: {
backgroundColor: 'white',
borderTopRightRadius: 10,
borderTopLeftRadius: 10,
paddingHorizontal: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10
},
contentContainer: {
height: '90%'
},
modalContentNew: {
width: '100%',
backgroundColor: 'white',
borderTopRightRadius: 18,
borderTopLeftRadius: 18,
paddingTop: 5,
paddingBottom: 5,
paddingHorizontal: 20
},
titleContainerNew: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
},
modalFloatContent: {
backgroundColor: 'white',
borderRadius: 18,
paddingTop: 5,
paddingBottom: 10,
paddingHorizontal: 20
},
titleContainerModalFloat: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
},
wrapBtnTab: {
justifyContent: 'space-between',
flexDirection: 'row',
marginBottom: 10,
borderRadius: 20,
padding: 5,
borderWidth: 1,
},
itemSelectModal: {
padding: 10,
flexDirection: 'row',
justifyContent: 'space-between',
borderBottomWidth: 1,
alignItems: 'center'
},
signDate: {
width: 20,
height: 2,
borderRadius: 3,
marginTop: 3
},
selectedDate: {
backgroundColor: '#238be6',
borderRadius: 5
},
selectRangeDate: {
backgroundColor: '#228be61f',
},
bottomMenuSelectDocument: {
paddingVertical: 10,
position: 'absolute',
width: '100%',
bottom: 0,
},
animatedView: {
width: '100%',
overflow: 'hidden',
},
wrapperAccordion: {
width: '100%',
position: 'absolute',
display: 'flex',
alignItems: 'center',
},
borderRight: {
borderRightWidth: 1,
borderRightColor: '#d6d8f6'
},
borderLeft: {
borderLeftWidth: 1,
borderLeftColor: '#d6d8f6'
},
borderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#d6d8f6'
},
borderTop: {
borderTopWidth: 1,
borderTopColor: '#d6d8f6'
},
borderAll: {
borderWidth: 1,
borderColor: '#d6d8f6'
},
absolute0: {
position: 'absolute',
bottom: 0
},
absoluteIcon: {
top: 18,
left: 20,
position: 'absolute'
},
toastContainer: {
backgroundColor: 'white',
borderRadius: 10,
padding: 10,
width: '90%',
borderWidth: 1,
borderColor: '#d6d8f6',
},
absoluteIconPicker: {
backgroundColor: '#384288',
padding: 5,
borderRadius: 100,
bottom: 5,
right: 5,
position: 'absolute'
},
hidden: {
position: 'absolute',
opacity: 0,
zIndex: -1,
},
headerContainer: {
backgroundColor: '#19345E',
},
headerApp: {
// height: 40,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
headerTitle: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
},
headerSide: {
width: 40,
alignItems: 'center',
},
chip: {
paddingVertical: 5,
paddingHorizontal: 15,
borderRadius: 5,
// backgroundColor: "#F3F4F6",
borderWidth: 1,
borderColor: "transparent",
marginRight: 10,
marginBottom: 10,
},
chipSelected: {
backgroundColor: "#f2f6ffff",
borderColor: "#384288",
borderWidth: 1,
},
chipText: {
fontSize: 16,
color: "#222",
},
chipTextSelected: {
color: "white",
},
checkIcon: {
position: "absolute",
top: -6,
left: -6,
backgroundColor: "#384288",
borderRadius: 10,
padding: 2,
},
headerModalViewImg: {
paddingTop: 50,
paddingHorizontal: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
wrapHomeCarousel: {
shadowColor: '#171717',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
justifyContent: 'center',
alignItems: 'center',
},
modalConfirmContainer: {
width: '80%',
borderRadius: 14,
overflow: 'hidden',
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
modalConfirmContent: {
padding: 20,
alignItems: 'center',
},
modalConfirmTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
textAlign: 'center',
},
modalConfirmMessage: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
modalConfirmDivider: {
height: 1,
width: '100%',
},
modalConfirmFooter: {
flexDirection: 'row',
height: 50,
},
modalConfirmButton: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
modalConfirmButtonText: {
fontSize: 16,
},
modalConfirmVerticalDivider: {
width: 1,
height: '100%',
},
wrapGridItem: {
borderWidth: 1,
borderRadius: 5,
padding: 10,
flexDirection: 'row',
alignItems: 'center',
width: '48.5%',
marginBottom: 10,
},
flex1: {
flex: 1
},
flex2: {
flex: 2
},
flexColumn: {
flexDirection: 'column'
},
textCenter: {
textAlign: 'center'
},
posAbsolute: {
position: 'absolute'
},
zIndex1: {
zIndex: 1
},
zIndexMinus1: {
zIndex: -1
},
alignCenter: {
alignItems: 'center'
},
itemsCenter: {
alignItems: 'center'
},
justifyCenter: {
justifyContent: 'center'
},
resizeContain: {
resizeMode: 'contain'
},
resizeCover: {
resizeMode: 'cover'
},
resizeStretch: {
resizeMode: 'stretch'
},
textWhite: {
color: 'white'
},
font16: {
fontSize: 16
},
font26: {
fontSize: 26
},
ph16: {
paddingHorizontal: 16
},
alignStart: {
alignItems: 'flex-start'
},
loadingCenter: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
backgroundColor: 'rgba(0,0,0,0.3)',
},
loadingBox: {
paddingVertical: 15,
paddingHorizontal: 40,
borderRadius: 5,
alignItems: 'center',
gap: 10,
},
modalUpdateContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 30,
overflow: 'hidden',
},
modalUpdateDecorativeCircle1: {
position: 'absolute',
width: 300,
height: 300,
borderRadius: 150,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
top: -50,
right: -50,
},
modalUpdateDecorativeCircle2: {
position: 'absolute',
width: 200,
height: 200,
borderRadius: 100,
backgroundColor: 'rgba(255, 255, 255, 0.03)',
bottom: -30,
left: -30,
},
modalUpdateContent: {
width: '100%',
alignItems: 'flex-start',
zIndex: 1,
},
modalUpdateLogo: {
width: 200,
height: 100,
marginBottom: 40,
alignSelf: 'center',
},
modalUpdateTextContainer: {
marginBottom: 40,
},
modalUpdateTitle: {
fontSize: 32,
fontWeight: 'bold',
color: 'white',
marginBottom: 20,
lineHeight: 38,
},
modalUpdateDescription: {
fontSize: 16,
color: 'white',
lineHeight: 24,
},
modalUpdateButtonContainer: {
width: '100%',
alignItems: 'center',
},
modalUpdatePrimaryButton: {
width: '100%',
paddingVertical: 15,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 15,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
modalUpdatePrimaryButtonText: {
fontSize: 16,
fontWeight: 'bold',
},
modalUpdateSecondaryButton: {
paddingVertical: 10,
},
modalUpdateSecondaryButtonText: {
fontSize: 16,
color: 'white',
fontWeight: '500',
},
})
export default Styles;
export { default } from './styles/index';

View File

@@ -0,0 +1,39 @@
import { StyleSheet } from "react-native";
const AnnouncementStyles = StyleSheet.create({
// list (index.tsx)
announcementListContainer: { padding: 15, paddingBottom: 0 },
announcementListInner: { marginTop: 10 },
announcementListCard: { borderRadius: 8, borderWidth: 1, padding: 12 },
announcementListCardHeader: { marginBottom: 6 },
announcementListTitleRow: { flex: 1, gap: 8, marginRight: 8, flexDirection: 'row', alignItems: 'center' },
announcementListIconBox: { width: 28, height: 28, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
announcementListTitleText: { flex: 1 },
announcementListDateText: { flexShrink: 0 },
announcementListDescText: { lineHeight: 20 },
announcementListSeparator: { height: 8 },
announcementListSkeletonCard: { gap: 8 },
announcementListSkeletonHeader: { marginBottom: 4 },
announcementListSkeletonTitleRow: { gap: 8, flex: 1, flexDirection: 'row', alignItems: 'center' },
// detail ([id].tsx)
announcementDetailContainer: { padding: 15, paddingBottom: 50, gap: 12 },
announcementDetailCard: { borderRadius: 8 },
announcementDetailSkeletonGap: { gap: 8 },
announcementDetailSkeletonIconRow: { gap: 10, marginBottom: 2 },
announcementDetailTitleRow: { gap: 10, marginBottom: 10 },
announcementDetailIconBox: { width: 38, height: 38, borderRadius: 8 },
announcementDetailTitleText: { fontSize: 17, lineHeight: 24, flex: 1 },
announcementDetailSectionLabelRow: { marginBottom: 8, gap: 6 },
announcementDetailFileCardPadding: { padding: 10 },
announcementDetailFileChipList: { gap: 8 },
announcementDetailFileChip: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 20, borderWidth: 1 },
announcementDetailFileChipText: { maxWidth: 120 },
announcementDetailRecipientGap: { gap: 10 },
announcementDetailGroupSeparator: { marginTop: 12, paddingTop: 12, borderTopWidth: 1 },
announcementDetailGroupLabel: { marginBottom: 6 },
announcementDetailDivisionRow: { gap: 8, paddingVertical: 6 },
announcementDetailDivisionIconCircle: { width: 26, height: 26, borderRadius: 100, alignItems: 'center', justifyContent: 'center' },
});
export default AnnouncementStyles;

View File

@@ -0,0 +1,13 @@
import { StyleSheet } from "react-native";
const ApprovalStyles = StyleSheet.create({
approvalBadge: { borderRadius: 20, paddingHorizontal: 10, paddingVertical: 3, alignSelf: 'flex-start' },
approvalItem: { borderWidth: 1, borderRadius: 8, padding: 12, marginBottom: 10 },
approvalItemHeader: { justifyContent: 'space-between', marginBottom: 8 },
approvalIconMr: { marginRight: 6 },
approvalNoteBox: { borderRadius: 8, padding: 8, marginTop: 4 },
approvalNoteLabel: { marginBottom: 2 },
approvalEmptyText: { textAlign: 'center' },
});
export default ApprovalStyles;

View File

@@ -0,0 +1,20 @@
import { StyleSheet } from "react-native";
const BorderStyles = StyleSheet.create({
round04: { borderRadius: 4 },
round05: { borderRadius: 8 },
round08: { borderRadius: 8 },
round10: { borderRadius: 10 },
round12: { borderRadius: 12 },
round15: { borderRadius: 15 },
round20: { borderRadius: 20 },
round30: { borderRadius: 30 },
roundFull: { borderRadius: 100 },
borderRight: { borderRightWidth: 1, borderRightColor: '#d6d8f6' },
borderLeft: { borderLeftWidth: 1, borderLeftColor: '#d6d8f6' },
borderBottom: { borderBottomWidth: 1, borderBottomColor: '#d6d8f6' },
borderTop: { borderTopWidth: 1, borderTopColor: '#d6d8f6' },
borderAll: { borderWidth: 1, borderColor: '#d6d8f6' },
});
export default BorderStyles;

View File

@@ -0,0 +1,44 @@
import { StyleSheet } from "react-native";
const ButtonStyles = StyleSheet.create({
btnIconHeader: { padding: 3 },
btnFiturMenu: { padding: 13, borderRadius: 20, borderWidth: 1 },
btnRound: {
backgroundColor: '#1F3C88',
borderWidth: 0,
borderColor: '#1F3C88',
alignItems: 'center',
borderRadius: 30,
marginTop: 15,
paddingVertical: 10,
},
btnTab: {
alignItems: 'center',
paddingVertical: 5,
paddingHorizontal: 15,
borderRadius: 20,
flexDirection: 'row',
justifyContent: 'center',
},
btnLainnya: {
alignSelf: 'flex-start',
backgroundColor: '#1F3C88',
paddingVertical: 5,
marginVertical: 5,
},
btnDisabled: { backgroundColor: '#d6d8f6' },
btnMenuRow: { width: '33%', alignItems: 'center' },
btnMenuRowMany: { alignItems: 'center', marginHorizontal: 10 },
wrapBtnTab: {
justifyContent: 'space-between',
flexDirection: 'row',
marginBottom: 10,
borderRadius: 20,
padding: 5,
borderWidth: 1,
},
labelStatus: { paddingHorizontal: 15, paddingVertical: 4, borderRadius: 20 },
labelStatusSmall: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 20 },
});
export default ButtonStyles;

View File

@@ -0,0 +1,159 @@
import { StyleSheet } from "react-native";
const CardStyles = StyleSheet.create({
wrapPaper: {
padding: 10,
backgroundColor: 'white',
borderRadius: 8,
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
},
shadowBox: {
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
},
noShadow: {
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
wrapGridContent: {
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
borderRadius: 8,
marginBottom: 15,
},
wrapGridCaraousel: {
width: '95%',
height: 200,
shadowColor: '#171717',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 2,
borderRadius: 8,
marginLeft: 5,
display: 'flex',
},
wrapHomeCarousel: {
shadowColor: '#171717',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
headerPaperGrid: {
paddingVertical: 25,
paddingHorizontal: 20,
alignItems: 'center',
borderTopStartRadius: 8,
borderTopEndRadius: 8,
},
contentPaperGrid: {
height: 125,
borderBottomEndRadius: 8,
borderBottomStartRadius: 8,
paddingHorizontal: 20,
justifyContent: 'space-evenly',
},
contentPaperGrid2: {
height: 100,
borderBottomEndRadius: 8,
borderBottomStartRadius: 8,
paddingHorizontal: 20,
paddingVertical: 15,
justifyContent: 'flex-start',
},
wrapGridItem: {
borderWidth: 1,
borderRadius: 8,
padding: 10,
flexDirection: 'row',
alignItems: 'center',
width: '48.5%',
marginBottom: 10,
},
listItemCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
borderRadius: 8,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 10,
gap: 12,
},
sectionCard: { borderRadius: 8, padding: 16, borderWidth: 1 },
sectionHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
sectionHeaderRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
},
sectionIconBox: {
width: 30,
height: 30,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
sectionActionRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
sectionBadge: { borderRadius: 20, paddingHorizontal: 8, paddingVertical: 2 },
wrapBar: { height: 20, backgroundColor: '#ccc', borderRadius: 10, margin: 0, width: '100%' },
contentBar: { height: 20, backgroundColor: '#3B82F6', borderRadius: 10 },
toastContainer: {
backgroundColor: 'white',
borderRadius: 8,
padding: 10,
width: '90%',
borderWidth: 1,
borderColor: '#d6d8f6',
},
loadingCenter: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
backgroundColor: 'rgba(0,0,0,0.3)',
},
loadingBox: {
paddingVertical: 15,
paddingHorizontal: 40,
borderRadius: 8,
alignItems: 'center',
gap: 10,
},
caraoselContent: {
flex: 1,
justifyContent: "center",
marginHorizontal: 15,
borderRadius: 8,
backgroundColor: '#19345E',
display: 'flex',
width: '92%',
resizeMode: 'stretch',
overflow: 'hidden',
},
wrapItemDiscussion: { padding: 15, borderRadius: 8, borderBottomWidth: 1 },
wrapItemBorderBottom: { padding: 10, borderBottomWidth: 1 },
wrapItemBorderAll: { padding: 10, borderWidth: 1, borderRadius: 8, marginBottom: 5 },
wrapItemBorderNone: { padding: 10, marginBottom: 5 },
});
export default CardStyles;

View File

@@ -0,0 +1,140 @@
import { StyleSheet } from "react-native";
const ComponentStyles = StyleSheet.create({
// avatar
userProfileExtraSmall: { width: 35, height: 35, borderRadius: 100 },
userProfileSmall: { width: 48, height: 48, borderRadius: 100 },
userProfileBig: { width: 100, height: 100, borderRadius: 100 },
imgListBanner: { width: 100, height: 50, borderRadius: 8 },
iconContent: { padding: 10, borderRadius: 100, backgroundColor: '#E5E7EB' },
// chip
chip: {
paddingVertical: 5,
paddingHorizontal: 15,
borderRadius: 20,
borderWidth: 1,
borderColor: "transparent",
marginRight: 10,
marginBottom: 10,
},
chipSelected: { backgroundColor: "#f2f6ffff", borderColor: "#384288", borderWidth: 1 },
chipText: { fontSize: 16, color: "#222" },
chipTextSelected: { color: "white" },
checkIcon: {
position: "absolute",
top: -6,
left: -6,
backgroundColor: "#384288",
borderRadius: 8,
padding: 2,
},
// badge & progress
badgeCol: { alignItems: 'center', gap: 6 },
progressBadge: { borderRadius: 8, paddingHorizontal: 12, paddingVertical: 5, borderWidth: 1, alignItems: 'center' },
taskCountBadge: { borderRadius: 8, paddingHorizontal: 7, paddingVertical: 2 },
positionBadge: { borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3 },
textProgressPercent: { fontSize: 22, fontWeight: 'bold', lineHeight: 28 },
progressTrack: { height: 8, borderRadius: 4, overflow: 'hidden' },
progressFill: { height: '100%', borderRadius: 4 },
reportContent: { borderLeftWidth: 3, paddingLeft: 12 },
expandBtn: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', marginTop: 8, gap: 4 },
fileGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
fileCard: { width: '48%', borderRadius: 8, borderWidth: 1, padding: 12, flexDirection: 'row', alignItems: 'center', gap: 10 },
// calendar
signDate: { width: 20, height: 2, borderRadius: 4, marginTop: 3 },
selectedDate: { backgroundColor: '#238be6', borderRadius: 4 },
selectRangeDate: { backgroundColor: '#228be61f' },
calendarDotRow: { flexDirection: 'row', gap: 2, height: 6, marginTop: 1 },
calendarDot: { width: 5, height: 5, borderRadius: 3 },
villageEventLegendRow: { marginTop: 10, marginBottom: 4, gap: 16 },
villageEventLegendItem: { gap: 6 },
villageEventLegendDot: { width: 10, height: 10, borderRadius: 5 },
villageEventBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, marginRight: 6 },
// event item
itemEvent: { padding: 10, borderRadius: 8, flexDirection: 'row', alignContent: 'stretch', marginBottom: 10 },
dividerEvent: { width: 7, borderRadius: 5, marginRight: 10 },
// member
memberAvatarRing: { borderWidth: 3, borderColor: 'rgba(255,255,255,0.4)', borderRadius: 100 },
memberBadgeRow: { flexDirection: 'row', gap: 8, marginTop: 12 },
memberBadgeApprover: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
backgroundColor: 'rgba(255,255,255,0.15)',
},
memberBadgePill: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 20 },
memberInfoRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 14 },
memberInfoIcon: { width: 36, alignItems: 'center' },
memberInfoContent: { flex: 1, marginLeft: 10 },
// discussion
discussionCard: { borderRadius: 8, borderWidth: 1, padding: 14 },
discussionIconCircle: { width: 40, height: 40, borderRadius: 100, alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
discussionIconCircleLg: { width: 44, height: 44, borderRadius: 100, alignItems: 'center', justifyContent: 'center' },
discussionStatusPill: { alignSelf: 'flex-start', marginTop: 3, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 20, borderWidth: 1 },
discussionStatusText: { fontSize: 11, fontWeight: '600' },
discussionCardIndent: { marginLeft: 50 },
discussionSeparator: { height: 8 },
discussionCommentText: { fontSize: 12, marginLeft: 5 },
discussionDateText: { fontSize: 11 },
discussionCommentCard: { borderRadius: 8, borderWidth: 1, padding: 12, marginBottom: 8, flexDirection: 'row' },
discussionEditedText: { fontSize: 10, fontStyle: 'italic' },
discussionHeaderPadding: { paddingTop: 12 },
discussionListPadding: { paddingTop: 8 },
discussionTitleCol: { marginLeft: 10 },
discussionDescMargin: { marginBottom: 10 },
discussionEmptyText: { fontSize: 14 },
// guide overlay
guideOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center' },
guideCard: {
position: 'absolute',
left: 24,
right: 24,
borderRadius: 8,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 8,
},
guideBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 20 },
guideDotRow: { marginTop: 16, gap: 6, justifyContent: 'center' },
guideDot: { width: 6, height: 6, borderRadius: 3 },
guideButtonPrimary: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20 },
guideButtonSecondary: { paddingHorizontal: 4, paddingVertical: 8 },
guideArrowUp: {
position: 'absolute',
top: -10,
marginLeft: -8,
width: 0,
height: 0,
borderLeftWidth: 8,
borderRightWidth: 8,
borderBottomWidth: 10,
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
guideArrowDown: {
position: 'absolute',
bottom: -10,
marginLeft: -8,
width: 0,
height: 0,
borderLeftWidth: 8,
borderRightWidth: 8,
borderTopWidth: 10,
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
});
export default ComponentStyles;

View File

@@ -0,0 +1,23 @@
import { StyleSheet } from "react-native";
const HeaderStyles = StyleSheet.create({
headerContainer: { backgroundColor: '#19345E' },
headerApp: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
headerTitle: { color: '#fff', fontSize: 18, fontWeight: '600' },
headerSide: { width: 40, alignItems: 'center' },
wrapHeadViewMember: {
backgroundColor: '#19345E',
paddingVertical: 30,
paddingHorizontal: 15,
alignItems: 'center',
borderBottomLeftRadius: 25,
borderBottomRightRadius: 25,
},
});
export default HeaderStyles;

32
constants/styles/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import { StyleSheet } from "react-native";
import SpacingStyles from './spacing.styles';
import TypographyStyles from './typography.styles';
import LayoutStyles from './layout.styles';
import BorderStyles from './border.styles';
import ButtonStyles from './button.styles';
import InputStyles from './input.styles';
import CardStyles from './card.styles';
import ModalStyles from './modal.styles';
import HeaderStyles from './header.styles';
import ComponentStyles from './component.styles';
import NotificationStyles from './notification.styles';
import ApprovalStyles from './approval.styles';
import AnnouncementStyles from './announcement.styles';
const Styles = StyleSheet.create({
...SpacingStyles,
...TypographyStyles,
...LayoutStyles,
...BorderStyles,
...ButtonStyles,
...InputStyles,
...CardStyles,
...ModalStyles,
...HeaderStyles,
...ComponentStyles,
...NotificationStyles,
...ApprovalStyles,
...AnnouncementStyles,
});
export default Styles;

View File

@@ -0,0 +1,35 @@
import { StyleSheet } from "react-native";
const InputStyles = StyleSheet.create({
inputRoundForm: {
borderRadius: 12,
borderColor: '#d6d8f6',
borderWidth: 1,
paddingVertical: 10,
paddingHorizontal: 15,
},
inputRoundFormLeft: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 0,
},
inputRoundFormRight: {
flexDirection: 'row-reverse',
alignItems: 'center',
paddingVertical: 0,
justifyContent: 'space-between',
},
verificationCell: {
width: 50,
height: 50,
lineHeight: 45,
fontSize: 24,
borderWidth: 1,
borderRadius: 12,
borderColor: 'gray',
textAlign: 'center',
},
verificationFocusCell: { borderColor: '#19345E' },
});
export default InputStyles;

View File

@@ -0,0 +1,52 @@
import { StyleSheet } from "react-native";
const LayoutStyles = StyleSheet.create({
wrapLogin: { flex: 1, justifyContent: "flex-start", alignItems: "stretch", padding: 20 },
flex1: { flex: 1 },
flex2: { flex: 2 },
flexColumn: { flexDirection: 'column' },
rowOnly: { flexDirection: 'row' },
rowSpaceBetween: { justifyContent: 'space-between', flexDirection: 'row' },
rowSpaceBetweenReverse: { justifyContent: 'space-between', flexDirection: 'row-reverse' },
rowItemsCenter: { flexDirection: 'row', alignItems: 'center' },
justifySpaceBetween: { justifyContent: 'space-between' },
justifyCenter: { justifyContent: 'center' },
alignCenter: { alignItems: 'center' },
itemsCenter: { alignItems: 'center' },
alignStart: { alignItems: 'flex-start' },
contentItemCenter: { justifyContent: 'center', alignItems: 'center' },
h100: { height: '100%' },
w30: { width: '30%' },
w40: { width: '40%' },
w45: { width: '45%' },
w48: { width: '48%' },
w50: { width: '50%' },
w60: { width: '60%' },
w70: { width: '70%' },
w80: { width: '80%' },
w90: { width: '90%' },
w95: { width: '95%' },
w100: { width: '100%' },
posAbsolute: { position: 'absolute' },
absolute0: { position: 'absolute', bottom: 0 },
absoluteIcon: { top: 18, left: 20, position: 'absolute' },
absoluteIconPicker: {
backgroundColor: '#384288',
padding: 5,
borderRadius: 100,
bottom: 5,
right: 5,
position: 'absolute',
},
hidden: { position: 'absolute', opacity: 0, zIndex: -1 },
zIndex1: { zIndex: 1 },
zIndexMinus1: { zIndex: -1 },
resizeContain: { resizeMode: 'contain' },
resizeCover: { resizeMode: 'cover' },
resizeStretch: { resizeMode: 'stretch' },
animatedView: { width: '100%', overflow: 'hidden' },
wrapperAccordion: { width: '100%', position: 'absolute', display: 'flex', alignItems: 'center' },
bottomMenuSelectDocument: { paddingVertical: 10, position: 'absolute', width: '100%', bottom: 0 },
});
export default LayoutStyles;

View File

@@ -0,0 +1,140 @@
import { StyleSheet } from "react-native";
const ModalStyles = StyleSheet.create({
modalBgTransparant: { backgroundColor: 'rgba(0, 0, 0, 0.3)', flex: 1 },
modalContent: {
width: '100%',
paddingBottom: 20,
backgroundColor: 'white',
borderTopRightRadius: 20,
borderTopLeftRadius: 20,
position: 'absolute',
bottom: 0,
},
modalContentNew: {
width: '100%',
backgroundColor: 'white',
borderTopRightRadius: 20,
borderTopLeftRadius: 20,
paddingTop: 5,
paddingBottom: 5,
paddingHorizontal: 20,
},
modalFloatContent: {
backgroundColor: 'white',
borderRadius: 20,
paddingTop: 5,
paddingBottom: 10,
paddingHorizontal: 20,
},
titleContainer: {
backgroundColor: 'white',
borderTopRightRadius: 20,
borderTopLeftRadius: 20,
paddingHorizontal: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
},
titleContainerNew: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
},
titleContainerModalFloat: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
},
contentContainer: { height: '90%' },
itemSelectModal: {
padding: 10,
flexDirection: 'row',
justifyContent: 'space-between',
borderBottomWidth: 1,
alignItems: 'center',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
justifyContent: 'center',
alignItems: 'center',
},
modalConfirmContainer: {
width: '80%',
borderRadius: 8,
overflow: 'hidden',
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
modalConfirmContent: { padding: 20, alignItems: 'center' },
modalConfirmTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 8, textAlign: 'center' },
modalConfirmMessage: { fontSize: 14, textAlign: 'center', lineHeight: 20 },
modalConfirmDivider: { height: 1, width: '100%' },
modalConfirmFooter: { flexDirection: 'row', height: 50 },
modalConfirmButton: { flex: 1, justifyContent: 'center', alignItems: 'center' },
modalConfirmButtonText: { fontSize: 16 },
modalConfirmVerticalDivider: { width: 1, height: '100%' },
modalUpdateContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 30,
overflow: 'hidden',
},
modalUpdateDecorativeCircle1: {
position: 'absolute',
width: 300,
height: 300,
borderRadius: 100,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
top: -50,
right: -50,
},
modalUpdateDecorativeCircle2: {
position: 'absolute',
width: 200,
height: 200,
borderRadius: 100,
backgroundColor: 'rgba(255, 255, 255, 0.03)',
bottom: -30,
left: -30,
},
modalUpdateContent: { width: '100%', alignItems: 'flex-start', zIndex: 1 },
modalUpdateLogo: { width: 200, height: 100, marginBottom: 40, alignSelf: 'center' },
modalUpdateTextContainer: { marginBottom: 40 },
modalUpdateTitle: { fontSize: 32, fontWeight: 'bold', color: 'white', marginBottom: 20, lineHeight: 38 },
modalUpdateDescription: { fontSize: 16, color: 'white', lineHeight: 24 },
modalUpdateButtonContainer: { width: '100%', alignItems: 'center' },
modalUpdatePrimaryButton: {
width: '100%',
paddingVertical: 15,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 15,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
modalUpdatePrimaryButtonText: { fontSize: 16, fontWeight: 'bold' },
modalUpdateSecondaryButton: { paddingVertical: 10 },
modalUpdateSecondaryButtonText: { fontSize: 16, color: 'white', fontWeight: '500' },
headerModalViewImg: {
paddingTop: 50,
paddingHorizontal: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
});
export default ModalStyles;

View File

@@ -0,0 +1,29 @@
import { StyleSheet } from "react-native";
const NotificationStyles = StyleSheet.create({
notifContainer: { paddingTop: 10 },
notifHeaderRow: { marginTop: 16, marginBottom: 8 },
notifDateSeparator: { flex: 1, height: 1, marginLeft: 8 },
notifDateText: { fontSize: 11, fontWeight: '600', letterSpacing: 0.6, textTransform: 'uppercase' },
notifItemRow: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 8,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 6,
},
notifIconContainer: {
width: 42,
height: 42,
borderRadius: 100,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
notifContent: { marginLeft: 10 },
notifMarkReadText: { fontSize: 11, color: '#3B82F6', fontWeight: '600' },
});
export default NotificationStyles;

View File

@@ -0,0 +1,60 @@
import { StyleSheet } from "react-native";
const SpacingStyles = StyleSheet.create({
mb05: { marginBottom: 5 },
mb08: { marginBottom: 8 },
mb10: { marginBottom: 10 },
mb12: { marginBottom: 12 },
mb13: { marginBottom: 13 },
mb15: { marginBottom: 15 },
mb20: { marginBottom: 20 },
mb30: { marginBottom: 30 },
mb50: { marginBottom: 50 },
mb100: { marginBottom: 100 },
mt02: { marginTop: 2 },
mt05: { marginTop: 5 },
mt10: { marginTop: 10 },
mt15: { marginTop: 15 },
mt30: { marginTop: 30 },
mr05: { marginRight: 5 },
mr10: { marginRight: 10 },
ml05: { marginLeft: 5 },
ml10: { marginLeft: 10 },
ml15: { marginLeft: 15 },
ml20: { marginLeft: 20 },
ml25: { marginLeft: 25 },
mv05: { marginVertical: 5 },
mv10: { marginVertical: 10 },
mv15: { marginVertical: 15 },
mv50: { marginVertical: 50 },
mh03: { marginHorizontal: 3 },
mh05: { marginHorizontal: 5 },
mh10: { marginHorizontal: 10 },
mh15: { marginHorizontal: 15 },
p0: { padding: 0 },
p05: { padding: 5 },
p10: { padding: 10 },
p15: { padding: 15 },
p20: { padding: 20 },
pb05: { paddingBottom: 5 },
pb07: { paddingBottom: 7 },
pb10: { paddingBottom: 10 },
pb13: { paddingBottom: 13 },
pb15: { paddingBottom: 15 },
pb20: { paddingBottom: 20 },
pb50: { paddingBottom: 50 },
pb100: { paddingBottom: 100 },
ph05: { paddingHorizontal: 5 },
ph10: { paddingHorizontal: 10 },
ph15: { paddingHorizontal: 15 },
ph16: { paddingHorizontal: 16 },
ph20: { paddingHorizontal: 20 },
pv03: { paddingVertical: 3 },
pv05: { paddingVertical: 5 },
pv10: { paddingVertical: 10 },
pv14: { paddingVertical: 14 },
pv15: { paddingVertical: 15 },
pv20: { paddingVertical: 20 },
});
export default SpacingStyles;

View File

@@ -0,0 +1,28 @@
import { StyleSheet } from "react-native";
const TypographyStyles = StyleSheet.create({
textTitle: { fontSize: 32, fontWeight: 'bold', lineHeight: 32 },
textSubtitle: { fontSize: 20, fontWeight: 'bold' },
textSubtitle2: { fontSize: 20 },
textHeaderHome: { fontSize: 18, fontWeight: 'bold', flex: 1, color: 'white' },
textDefault: { fontSize: 15, lineHeight: 24 },
textDefaultSemiBold: { fontSize: 15, lineHeight: 24, fontWeight: '600' },
textMediumNormal: { fontSize: 13, lineHeight: 24 },
textMediumSemiBold: { fontSize: 13, lineHeight: 24, fontWeight: '600' },
textSmallSemiBold: { fontSize: 10, fontWeight: '600' },
textInformation: { fontSize: 12, fontWeight: 'light' },
textLink: { fontSize: 14, color: '#0a7ea4' },
textCenter: { textAlign: 'center' },
textWhite: { color: 'white' },
font16: { fontSize: 16 },
font26: { fontSize: 26 },
cError: { color: '#DB1514' },
cGray: { color: 'gray' },
cWhite: { color: 'white' },
cWhiteDimmed: { color: 'rgba(255,255,255,0.7)' },
cBlack: { color: 'black' },
cDefault: { color: '#19345E' },
cFolder: { color: '#f9cc40' },
});
export default TypographyStyles;

134
docs/FILE-HEALTH.md Normal file
View File

@@ -0,0 +1,134 @@
# FILE-HEALTH — Aturan Ukuran & Struktur File
Aturan ini berlaku untuk semua file dalam project ini.
Tujuan: menjaga file tetap kecil, kohesif, dan mudah diproses oleh AI maupun manusia.
---
## Batas Ukuran File
| Tipe File | Maks Baris | Maks Karakter | Keterangan |
|-----------|-----------|---------------|------------|
| Route handler | 150 | 6.000 | Satu file = satu resource |
| Service / use-case | 300 | 12.000 | Satu file = satu domain logic |
| Repository / query | 250 | 10.000 | Pisah per entity |
| Schema / validation | 200 | 8.000 | Pisah per domain |
| Types / interfaces | 300 | 10.000 | Boleh agregat, tapi per modul |
| Utility / helper | 200 | 8.000 | Satu concern per file |
| Config | 100 | 4.000 | Tidak ada logic bisnis |
| Test file | 400 | 16.000 | Satu file test per satu unit |
> **Hard limit global:** Tidak ada file yang boleh melebihi **500 baris** atau **20.000 karakter**,
> kecuali file yang di-generate otomatis (migration, seed, generated types).
---
## Aturan Wajib
### 1. Satu File, Satu Tanggung Jawab
- Setiap file harus bisa dijelaskan dalam satu kalimat pendek.
- Jika penjelasannya butuh kata "dan" lebih dari sekali → **pecah file-nya**.
### 2. Tidak Ada "God File"
- Dilarang menaruh lebih dari satu route group dalam satu file handler.
- Dilarang mencampur business logic dengan transport layer (HTTP, WS, queue).
- Dilarang mencampur type definition dengan implementation dalam satu file yang panjang.
### 3. Penamaan File Harus Eksplisit
- Nama file harus mencerminkan isi secara tepat.
- Hindari nama generik: `utils.ts`, `helpers.ts`, `common.ts`, `misc.ts`.
- Gunakan pola: `[domain].[layer].ts` → contoh: `user.service.ts`, `payment.repository.ts`.
### 4. Index File Hanya Untuk Re-export
- File `index.ts` hanya boleh berisi re-export, **bukan** implementasi.
- Maksimal 50 baris untuk file index.
### 5. Tidak Ada Barrel Import yang Dalam
- Hindari barrel yang mengimpor dari barrel lain lebih dari 2 level.
- Ini membuat AI sulit trace dependency dengan akurat.
---
## Kapan Harus Pecah File
Pecah file segera jika salah satu kondisi ini terpenuhi:
- [ ] File melebihi batas karakter/baris di tabel di atas
- [ ] Ada dua fungsi/class yang tidak saling bergantung dalam satu file
- [ ] File mengandung lebih dari 3 exported symbol utama
- [ ] File sulit diberi nama yang spesifik tanpa kata "dan"
- [ ] Edit di satu bagian file sering menyebabkan konflik di bagian lain
---
## Pola Pemecahan File yang Dianjurkan
### Service yang Terlalu Besar
```
// SEBELUM: user.service.ts (600 baris)
// SESUDAH:
user.service.ts // orchestration, max 150 baris
user.query.service.ts // read operations
user.command.service.ts // write operations
user.notification.service.ts // side effects
```
### Handler yang Terlalu Besar
```
// SEBELUM: user.route.ts (400 baris)
// SESUDAH:
user.route.ts // route registration only
user.handler.ts // handler functions
user.middleware.ts // route-specific middleware
```
### Types yang Terlalu Besar
```
// SEBELUM: types.ts (500 baris)
// SESUDAH:
types/user.types.ts
types/payment.types.ts
types/shared.types.ts
```
---
## Instruksi Khusus untuk AI
Ketika bekerja dalam project ini, **Claude wajib**:
1. **Menolak menambah kode** ke file yang sudah mendekati atau melebihi batas,
kecuali penambahannya memang sangat kecil (< 10 baris) dan kohesif.
2. **Proaktif menyarankan refactor** saat mendeteksi file yang tumbuh tidak sehat,
sebelum menambahkan fitur baru ke file tersebut.
3. **Tidak membuat "helper dump"** setiap helper harus punya file sendiri
yang namanya spesifik, bukan ditumpuk ke file utils yang ada.
4. **Selalu buat file baru** jika implementasi baru tidak secara alami masuk
ke salah satu file yang sudah ada.
5. **Periksa ukuran file saat ini** sebelum mengedit jika sudah > 80% dari
batas, sarankan pecah terlebih dahulu.
---
## Pengecualian
File berikut **dikecualikan** dari aturan batas ukuran:
- `*.generated.ts` — file hasil code generation (Prisma, tRPC, dll)
- `*.migration.ts` / `*_migration.sql` — file migrasi database
- `*.seed.ts` — file seeding data
- File di folder `__fixtures__/` atau `__mocks__/`
Pengecualian **tidak berlaku** untuk file konfigurasi runtime seperti
`elysia.config.ts`, `app.ts`, atau `server.ts` — file ini tetap harus ringkas.
---
*Letakkan file ini di root project atau sertakan referensinya di `CLAUDE.md`.*

View File

@@ -394,7 +394,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = mobiledarmasaba.app;
PRODUCT_NAME = Desa;
PRODUCT_NAME = "Desa";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Desa/Desa-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -429,7 +429,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = mobiledarmasaba.app;
PRODUCT_NAME = Desa;
PRODUCT_NAME = "Desa";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Desa/Desa-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View File

@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>2.1.0</string>
<string>2.2.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -39,7 +39,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>9</string>
<string>10</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>

Some files were not shown because too many files have changed in this diff Show More