stock request
This commit is contained in:
156
go.work.sum
Normal file
156
go.work.sum
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
cloud.google.com/go/accessapproval v1.7.1/go.mod h1:JYczztsHRMK7NTXb6Xw+dwbs/WnOJxbo/2mTI+Kgg68=
|
||||||
|
cloud.google.com/go/accesscontextmanager v1.8.1/go.mod h1:JFJHfvuaTC+++1iL1coPiG1eu5D24db2wXCDWDjIrxo=
|
||||||
|
cloud.google.com/go/aiplatform v1.48.0/go.mod h1:Iu2Q7sC7QGhXUeOhAj/oCK9a+ULz1O4AotZiqjQ8MYA=
|
||||||
|
cloud.google.com/go/analytics v0.21.3/go.mod h1:U8dcUtmDmjrmUTnnnRnI4m6zKn/yaA5N9RlEkYFHpQo=
|
||||||
|
cloud.google.com/go/apigateway v1.6.1/go.mod h1:ufAS3wpbRjqfZrzpvLC2oh0MFlpRJm2E/ts25yyqmXA=
|
||||||
|
cloud.google.com/go/apigeeconnect v1.6.1/go.mod h1:C4awq7x0JpLtrlQCr8AzVIzAaYgngRqWf9S5Uhg+wWs=
|
||||||
|
cloud.google.com/go/apigeeregistry v0.7.1/go.mod h1:1XgyjZye4Mqtw7T9TsY4NW10U7BojBvG4RMD+vRDrIw=
|
||||||
|
cloud.google.com/go/appengine v1.8.1/go.mod h1:6NJXGLVhZCN9aQ/AEDvmfzKEfoYBlfB80/BHiKVputY=
|
||||||
|
cloud.google.com/go/area120 v0.8.1/go.mod h1:BVfZpGpB7KFVNxPiQBuHkX6Ed0rS51xIgmGyjrAfzsg=
|
||||||
|
cloud.google.com/go/artifactregistry v1.14.1/go.mod h1:nxVdG19jTaSTu7yA7+VbWL346r3rIdkZ142BSQqhn5E=
|
||||||
|
cloud.google.com/go/asset v1.14.1/go.mod h1:4bEJ3dnHCqWCDbWJ/6Vn7GVI9LerSi7Rfdi03hd+WTQ=
|
||||||
|
cloud.google.com/go/assuredworkloads v1.11.1/go.mod h1:+F04I52Pgn5nmPG36CWFtxmav6+7Q+c5QyJoL18Lry0=
|
||||||
|
cloud.google.com/go/automl v1.13.1/go.mod h1:1aowgAHWYZU27MybSCFiukPO7xnyawv7pt3zK4bheQE=
|
||||||
|
cloud.google.com/go/baremetalsolution v1.1.1/go.mod h1:D1AV6xwOksJMV4OSlWHtWuFNZZYujJknMAP4Qa27QIA=
|
||||||
|
cloud.google.com/go/batch v1.3.1/go.mod h1:VguXeQKXIYaeeIYbuozUmBR13AfL4SJP7IltNPS+A4A=
|
||||||
|
cloud.google.com/go/beyondcorp v1.0.0/go.mod h1:YhxDWw946SCbmcWo3fAhw3V4XZMSpQ/VYfcKGAEU8/4=
|
||||||
|
cloud.google.com/go/bigquery v1.53.0/go.mod h1:3b/iXjRQGU4nKa87cXeg6/gogLjO8C6PmuM8i5Bi/u4=
|
||||||
|
cloud.google.com/go/billing v1.16.0/go.mod h1:y8vx09JSSJG02k5QxbycNRrN7FGZB6F3CAcgum7jvGA=
|
||||||
|
cloud.google.com/go/binaryauthorization v1.6.1/go.mod h1:TKt4pa8xhowwffiBmbrbcxijJRZED4zrqnwZ1lKH51U=
|
||||||
|
cloud.google.com/go/certificatemanager v1.7.1/go.mod h1:iW8J3nG6SaRYImIa+wXQ0g8IgoofDFRp5UMzaNk1UqI=
|
||||||
|
cloud.google.com/go/channel v1.16.0/go.mod h1:eN/q1PFSl5gyu0dYdmxNXscY/4Fi7ABmeHCJNf/oHmc=
|
||||||
|
cloud.google.com/go/cloudbuild v1.13.0/go.mod h1:lyJg7v97SUIPq4RC2sGsz/9tNczhyv2AjML/ci4ulzU=
|
||||||
|
cloud.google.com/go/clouddms v1.6.1/go.mod h1:Ygo1vL52Ov4TBZQquhz5fiw2CQ58gvu+PlS6PVXCpZI=
|
||||||
|
cloud.google.com/go/cloudtasks v1.12.1/go.mod h1:a9udmnou9KO2iulGscKR0qBYjreuX8oHwpmFsKspEvM=
|
||||||
|
cloud.google.com/go/contactcenterinsights v1.10.0/go.mod h1:bsg/R7zGLYMVxFFzfh9ooLTruLRCG9fnzhH9KznHhbM=
|
||||||
|
cloud.google.com/go/container v1.24.0/go.mod h1:lTNExE2R7f+DLbAN+rJiKTisauFCaoDq6NURZ83eVH4=
|
||||||
|
cloud.google.com/go/containeranalysis v0.10.1/go.mod h1:Ya2jiILITMY68ZLPaogjmOMNkwsDrWBSTyBubGXO7j0=
|
||||||
|
cloud.google.com/go/datacatalog v1.16.0/go.mod h1:d2CevwTG4yedZilwe+v3E3ZBDRMobQfSG/a6cCCN5R4=
|
||||||
|
cloud.google.com/go/dataflow v0.9.1/go.mod h1:Wp7s32QjYuQDWqJPFFlnBKhkAtiFpMTdg00qGbnIHVw=
|
||||||
|
cloud.google.com/go/dataform v0.8.1/go.mod h1:3BhPSiw8xmppbgzeBbmDvmSWlwouuJkXsXsb8UBih9M=
|
||||||
|
cloud.google.com/go/datafusion v1.7.1/go.mod h1:KpoTBbFmoToDExJUso/fcCiguGDk7MEzOWXUsJo0wsI=
|
||||||
|
cloud.google.com/go/datalabeling v0.8.1/go.mod h1:XS62LBSVPbYR54GfYQsPXZjTW8UxCK2fkDciSrpRFdY=
|
||||||
|
cloud.google.com/go/dataplex v1.9.0/go.mod h1:7TyrDT6BCdI8/38Uvp0/ZxBslOslP2X2MPDucliyvSE=
|
||||||
|
cloud.google.com/go/dataproc/v2 v2.0.1/go.mod h1:7Ez3KRHdFGcfY7GcevBbvozX+zyWGcwLJvvAMwCaoZ4=
|
||||||
|
cloud.google.com/go/dataqna v0.8.1/go.mod h1:zxZM0Bl6liMePWsHA8RMGAfmTG34vJMapbHAxQ5+WA8=
|
||||||
|
cloud.google.com/go/datastore v1.13.0/go.mod h1:KjdB88W897MRITkvWWJrg2OUtrR5XVj1EoLgSp6/N70=
|
||||||
|
cloud.google.com/go/datastream v1.10.0/go.mod h1:hqnmr8kdUBmrnk65k5wNRoHSCYksvpdZIcZIEl8h43Q=
|
||||||
|
cloud.google.com/go/deploy v1.13.0/go.mod h1:tKuSUV5pXbn67KiubiUNUejqLs4f5cxxiCNCeyl0F2g=
|
||||||
|
cloud.google.com/go/dialogflow v1.40.0/go.mod h1:L7jnH+JL2mtmdChzAIcXQHXMvQkE3U4hTaNltEuxXn4=
|
||||||
|
cloud.google.com/go/dlp v1.10.1/go.mod h1:IM8BWz1iJd8njcNcG0+Kyd9OPnqnRNkDV8j42VT5KOI=
|
||||||
|
cloud.google.com/go/documentai v1.22.0/go.mod h1:yJkInoMcK0qNAEdRnqY/D5asy73tnPe88I1YTZT+a8E=
|
||||||
|
cloud.google.com/go/domains v0.9.1/go.mod h1:aOp1c0MbejQQ2Pjf1iJvnVyT+z6R6s8pX66KaCSDYfE=
|
||||||
|
cloud.google.com/go/edgecontainer v1.1.1/go.mod h1:O5bYcS//7MELQZs3+7mabRqoWQhXCzenBu0R8bz2rwk=
|
||||||
|
cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU=
|
||||||
|
cloud.google.com/go/essentialcontacts v1.6.2/go.mod h1:T2tB6tX+TRak7i88Fb2N9Ok3PvY3UNbUsMag9/BARh4=
|
||||||
|
cloud.google.com/go/eventarc v1.13.0/go.mod h1:mAFCW6lukH5+IZjkvrEss+jmt2kOdYlN8aMx3sRJiAI=
|
||||||
|
cloud.google.com/go/filestore v1.7.1/go.mod h1:y10jsorq40JJnjR/lQ8AfFbbcGlw3g+Dp8oN7i7FjV4=
|
||||||
|
cloud.google.com/go/functions v1.15.1/go.mod h1:P5yNWUTkyU+LvW/S9O6V+V423VZooALQlqoXdoPz5AE=
|
||||||
|
cloud.google.com/go/gkebackup v1.3.0/go.mod h1:vUDOu++N0U5qs4IhG1pcOnD1Mac79xWy6GoBFlWCWBU=
|
||||||
|
cloud.google.com/go/gkeconnect v0.8.1/go.mod h1:KWiK1g9sDLZqhxB2xEuPV8V9NYzrqTUmQR9shJHpOZw=
|
||||||
|
cloud.google.com/go/gkehub v0.14.1/go.mod h1:VEXKIJZ2avzrbd7u+zeMtW00Y8ddk/4V9511C9CQGTY=
|
||||||
|
cloud.google.com/go/gkemulticloud v1.0.0/go.mod h1:kbZ3HKyTsiwqKX7Yw56+wUGwwNZViRnxWK2DVknXWfw=
|
||||||
|
cloud.google.com/go/gsuiteaddons v1.6.1/go.mod h1:CodrdOqRZcLp5WOwejHWYBjZvfY0kOphkAKpF/3qdZY=
|
||||||
|
cloud.google.com/go/iap v1.8.1/go.mod h1:sJCbeqg3mvWLqjZNsI6dfAtbbV1DL2Rl7e1mTyXYREQ=
|
||||||
|
cloud.google.com/go/ids v1.4.1/go.mod h1:np41ed8YMU8zOgv53MMMoCntLTn2lF+SUzlM+O3u/jw=
|
||||||
|
cloud.google.com/go/iot v1.7.1/go.mod h1:46Mgw7ev1k9KqK1ao0ayW9h0lI+3hxeanz+L1zmbbbk=
|
||||||
|
cloud.google.com/go/kms v1.15.0/go.mod h1:c9J991h5DTl+kg7gi3MYomh12YEENGrf48ee/N/2CDM=
|
||||||
|
cloud.google.com/go/language v1.10.1/go.mod h1:CPp94nsdVNiQEt1CNjF5WkTcisLiHPyIbMhvR8H2AW0=
|
||||||
|
cloud.google.com/go/lifesciences v0.9.1/go.mod h1:hACAOd1fFbCGLr/+weUKRAJas82Y4vrL3O5326N//Wc=
|
||||||
|
cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M=
|
||||||
|
cloud.google.com/go/managedidentities v1.6.1/go.mod h1:h/irGhTN2SkZ64F43tfGPMbHnypMbu4RB3yl8YcuEak=
|
||||||
|
cloud.google.com/go/maps v1.4.0/go.mod h1:6mWTUv+WhnOwAgjVsSW2QPPECmW+s3PcRyOa9vgG/5s=
|
||||||
|
cloud.google.com/go/mediatranslation v0.8.1/go.mod h1:L/7hBdEYbYHQJhX2sldtTO5SZZ1C1vkapubj0T2aGig=
|
||||||
|
cloud.google.com/go/memcache v1.10.1/go.mod h1:47YRQIarv4I3QS5+hoETgKO40InqzLP6kpNLvyXuyaA=
|
||||||
|
cloud.google.com/go/metastore v1.12.0/go.mod h1:uZuSo80U3Wd4zi6C22ZZliOUJ3XeM/MlYi/z5OAOWRA=
|
||||||
|
cloud.google.com/go/monitoring v1.15.1/go.mod h1:lADlSAlFdbqQuwwpaImhsJXu1QSdd3ojypXrFSMr2rM=
|
||||||
|
cloud.google.com/go/networkconnectivity v1.12.1/go.mod h1:PelxSWYM7Sh9/guf8CFhi6vIqf19Ir/sbfZRUwXh92E=
|
||||||
|
cloud.google.com/go/networkmanagement v1.8.0/go.mod h1:Ho/BUGmtyEqrttTgWEe7m+8vDdK74ibQc+Be0q7Fof0=
|
||||||
|
cloud.google.com/go/networksecurity v0.9.1/go.mod h1:MCMdxOKQ30wsBI1eI659f9kEp4wuuAueoC9AJKSPWZQ=
|
||||||
|
cloud.google.com/go/notebooks v1.9.1/go.mod h1:zqG9/gk05JrzgBt4ghLzEepPHNwE5jgPcHZRKhlC1A8=
|
||||||
|
cloud.google.com/go/optimization v1.4.1/go.mod h1:j64vZQP7h9bO49m2rVaTVoNM0vEBEN5eKPUPbZyXOrk=
|
||||||
|
cloud.google.com/go/orchestration v1.8.1/go.mod h1:4sluRF3wgbYVRqz7zJ1/EUNc90TTprliq9477fGobD8=
|
||||||
|
cloud.google.com/go/orgpolicy v1.11.1/go.mod h1:8+E3jQcpZJQliP+zaFfayC2Pg5bmhuLK755wKhIIUCE=
|
||||||
|
cloud.google.com/go/osconfig v1.12.1/go.mod h1:4CjBxND0gswz2gfYRCUoUzCm9zCABp91EeTtWXyz0tE=
|
||||||
|
cloud.google.com/go/oslogin v1.10.1/go.mod h1:x692z7yAue5nE7CsSnoG0aaMbNoRJRXO4sn73R+ZqAs=
|
||||||
|
cloud.google.com/go/phishingprotection v0.8.1/go.mod h1:AxonW7GovcA8qdEk13NfHq9hNx5KPtfxXNeUxTDxB6I=
|
||||||
|
cloud.google.com/go/policytroubleshooter v1.8.0/go.mod h1:tmn5Ir5EToWe384EuboTcVQT7nTag2+DuH3uHmKd1HU=
|
||||||
|
cloud.google.com/go/privatecatalog v0.9.1/go.mod h1:0XlDXW2unJXdf9zFz968Hp35gl/bhF4twwpXZAW50JA=
|
||||||
|
cloud.google.com/go/pubsub v1.33.0/go.mod h1:f+w71I33OMyxf9VpMVcZbnG5KSUkCOUHYpFd5U1GdRc=
|
||||||
|
cloud.google.com/go/pubsublite v1.8.1/go.mod h1:fOLdU4f5xldK4RGJrBMm+J7zMWNj/k4PxwEZXy39QS0=
|
||||||
|
cloud.google.com/go/recaptchaenterprise/v2 v2.7.2/go.mod h1:kR0KjsJS7Jt1YSyWFkseQ756D45kaYNTlDPPaRAvDBU=
|
||||||
|
cloud.google.com/go/recommendationengine v0.8.1/go.mod h1:MrZihWwtFYWDzE6Hz5nKcNz3gLizXVIDI/o3G1DLcrE=
|
||||||
|
cloud.google.com/go/recommender v1.10.1/go.mod h1:XFvrE4Suqn5Cq0Lf+mCP6oBHD/yRMA8XxP5sb7Q7gpA=
|
||||||
|
cloud.google.com/go/redis v1.13.1/go.mod h1:VP7DGLpE91M6bcsDdMuyCm2hIpB6Vp2hI090Mfd1tcg=
|
||||||
|
cloud.google.com/go/resourcemanager v1.9.1/go.mod h1:dVCuosgrh1tINZ/RwBufr8lULmWGOkPS8gL5gqyjdT8=
|
||||||
|
cloud.google.com/go/resourcesettings v1.6.1/go.mod h1:M7mk9PIZrC5Fgsu1kZJci6mpgN8o0IUzVx3eJU3y4Jw=
|
||||||
|
cloud.google.com/go/retail v1.14.1/go.mod h1:y3Wv3Vr2k54dLNIrCzenyKG8g8dhvhncT2NcNjb/6gE=
|
||||||
|
cloud.google.com/go/run v1.2.0/go.mod h1:36V1IlDzQ0XxbQjUx6IYbw8H3TJnWvhii963WW3B/bo=
|
||||||
|
cloud.google.com/go/scheduler v1.10.1/go.mod h1:R63Ldltd47Bs4gnhQkmNDse5w8gBRrhObZ54PxgR2Oo=
|
||||||
|
cloud.google.com/go/secretmanager v1.11.1/go.mod h1:znq9JlXgTNdBeQk9TBW/FnR/W4uChEKGeqQWAJ8SXFw=
|
||||||
|
cloud.google.com/go/security v1.15.1/go.mod h1:MvTnnbsWnehoizHi09zoiZob0iCHVcL4AUBj76h9fXA=
|
||||||
|
cloud.google.com/go/securitycenter v1.23.0/go.mod h1:8pwQ4n+Y9WCWM278R8W3nF65QtY172h4S8aXyI9/hsQ=
|
||||||
|
cloud.google.com/go/servicedirectory v1.11.0/go.mod h1:Xv0YVH8s4pVOwfM/1eMTl0XJ6bzIOSLDt8f8eLaGOxQ=
|
||||||
|
cloud.google.com/go/shell v1.7.1/go.mod h1:u1RaM+huXFaTojTbW4g9P5emOrrmLE69KrxqQahKn4g=
|
||||||
|
cloud.google.com/go/spanner v1.47.0/go.mod h1:IXsJwVW2j4UKs0eYDqodab6HgGuA1bViSqW4uH9lfUI=
|
||||||
|
cloud.google.com/go/speech v1.19.0/go.mod h1:8rVNzU43tQvxDaGvqOhpDqgkJTFowBpDvCJ14kGlJYo=
|
||||||
|
cloud.google.com/go/storagetransfer v1.10.0/go.mod h1:DM4sTlSmGiNczmV6iZyceIh2dbs+7z2Ayg6YAiQlYfA=
|
||||||
|
cloud.google.com/go/talent v1.6.2/go.mod h1:CbGvmKCG61mkdjcqTcLOkb2ZN1SrQI8MDyma2l7VD24=
|
||||||
|
cloud.google.com/go/texttospeech v1.7.1/go.mod h1:m7QfG5IXxeneGqTapXNxv2ItxP/FS0hCZBwXYqucgSk=
|
||||||
|
cloud.google.com/go/tpu v1.6.1/go.mod h1:sOdcHVIgDEEOKuqUoi6Fq53MKHJAtOwtz0GuKsWSH3E=
|
||||||
|
cloud.google.com/go/trace v1.10.1/go.mod h1:gbtL94KE5AJLH3y+WVpfWILmqgc6dXcqgNXdOPAQTYk=
|
||||||
|
cloud.google.com/go/translate v1.8.2/go.mod h1:d1ZH5aaOA0CNhWeXeC8ujd4tdCFw8XoNWRljklu5RHs=
|
||||||
|
cloud.google.com/go/video v1.19.0/go.mod h1:9qmqPqw/Ib2tLqaeHgtakU+l5TcJxCJbhFXM7UJjVzU=
|
||||||
|
cloud.google.com/go/videointelligence v1.11.1/go.mod h1:76xn/8InyQHarjTWsBR058SmlPCwQjgcvoW0aZykOvo=
|
||||||
|
cloud.google.com/go/vision/v2 v2.7.2/go.mod h1:jKa8oSYBWhYiXarHPvP4USxYANYUEdEsQrloLjrSwJU=
|
||||||
|
cloud.google.com/go/vmmigration v1.7.1/go.mod h1:WD+5z7a/IpZ5bKK//YmT9E047AD+rjycCAvyMxGJbro=
|
||||||
|
cloud.google.com/go/vmwareengine v1.0.0/go.mod h1:Px64x+BvjPZwWuc4HdmVhoygcXqEkGHXoa7uyfTgSI0=
|
||||||
|
cloud.google.com/go/vpcaccess v1.7.1/go.mod h1:FogoD46/ZU+JUBX9D606X21EnxiszYi2tArQwLY4SXs=
|
||||||
|
cloud.google.com/go/webrisk v1.9.1/go.mod h1:4GCmXKcOa2BZcZPn6DCEvE7HypmEJcJkr4mtM+sqYPc=
|
||||||
|
cloud.google.com/go/websecurityscanner v1.6.1/go.mod h1:Njgaw3rttgRHXzwCB8kgCYqv5/rGpFCsBOvPbYgszpg=
|
||||||
|
cloud.google.com/go/workflows v1.11.1/go.mod h1:Z+t10G1wF7h8LgdY/EmRcQY8ptBD/nvofaL6FqlET6g=
|
||||||
|
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
|
||||||
|
github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
|
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE=
|
||||||
|
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ=
|
||||||
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
|
||||||
|
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
||||||
|
github.com/hashicorp/consul/api v1.25.1/go.mod h1:iiLVwR/htV7mas/sy0O+XSuEnrdBUUydemjxcUrAt4g=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
|
github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
|
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/nats-io/jwt/v2 v2.4.1/go.mod h1:24BeQtRwxRV8ruvC4CojXlx/WQ/VjuwlYiH+vu/+ibI=
|
||||||
|
github.com/nats-io/nats.go v1.30.2/go.mod h1:dcfhUgmQNN4GJEfIb2f9R7Fow+gzBF4emzDHrVBd5qM=
|
||||||
|
github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
|
||||||
|
github.com/sagikazarmark/crypt v0.15.0/go.mod h1:5rwNNax6Mlk9sZ40AcyVtiEw24Z4J04cfSioF2COKmc=
|
||||||
|
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
|
||||||
|
go.etcd.io/etcd/api/v3 v3.5.9/go.mod h1:uyAal843mC8uUVSLWz6eHa/d971iDGnCRpmKd2Z+X8k=
|
||||||
|
go.etcd.io/etcd/client/pkg/v3 v3.5.9/go.mod h1:y+CzeSmkMpWN2Jyu1npecjB9BBnABxGM4pN8cGuJeL4=
|
||||||
|
go.etcd.io/etcd/client/v2 v2.305.9/go.mod h1:0NBdNx9wbxtEQLwAQtrDHwx58m02vXpDcgSYI2seohQ=
|
||||||
|
go.etcd.io/etcd/client/v3 v3.5.9/go.mod h1:i/Eo5LrZ5IKqpbtpPDuaUnDOUv471oDg8cjQaUr2MbA=
|
||||||
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
google.golang.org/genproto/googleapis/bytestream v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:qDbnxtViX5J6CvFbxeNUSzKgVlDLJ/6L+caxye9+Flo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
58
package-lock.json
generated
58
package-lock.json
generated
@@ -22,6 +22,7 @@
|
|||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-router-dom": "^7.2.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3"
|
||||||
},
|
},
|
||||||
@@ -3722,6 +3723,57 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
||||||
|
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
||||||
|
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.18.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router/node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "3.8.1",
|
"version": "3.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
@@ -3922,6 +3974,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-router-dom": "^7.2.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3"
|
||||||
},
|
},
|
||||||
|
|||||||
12
scratch/test.js
Normal file
12
scratch/test.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const req = https.request('https://fiesta.nearle.app/live/api/v1/web/users/getallusers?tenantid=1087&pagesize=500', {
|
||||||
|
method: 'GET'
|
||||||
|
}, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.end();
|
||||||
153
src/App.tsx
153
src/App.tsx
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Network,
|
Network,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -29,7 +30,8 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Store,
|
Store,
|
||||||
Settings,
|
Settings,
|
||||||
LayoutDashboard
|
LayoutDashboard,
|
||||||
|
Users
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { MainSection } from './types';
|
import { MainSection } from './types';
|
||||||
@@ -37,9 +39,10 @@ import {
|
|||||||
useFiestaTenantLocations,
|
useFiestaTenantLocations,
|
||||||
useFiestaLocationSummary,
|
useFiestaLocationSummary,
|
||||||
useFiestaUpdateLocation,
|
useFiestaUpdateLocation,
|
||||||
|
useFiestaCreateLocation,
|
||||||
useFiestaOrderSummary,
|
useFiestaOrderSummary,
|
||||||
} from './services/fiestaQueries';
|
} from './services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, str as fstr } from './services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr, num as fnum } from './services/fiestaApi';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import DashboardView from './components/DashboardView';
|
import DashboardView from './components/DashboardView';
|
||||||
@@ -97,13 +100,14 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Navigation indicators states
|
// Navigation indicators states
|
||||||
const [currentSection, setCurrentSection] = useState<MainSection>('dashboard');
|
const location = useLocation();
|
||||||
const [selectedStore, setSelectedStore] = useState<{ locationid?: number; name: string; zone: string; deliveries: number; sales: string; orders?: number; staff: string; color: string; status: string } | null>(null);
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleSetSection = (sec: MainSection) => {
|
// Derive current section from URL path, default to 'dashboard'
|
||||||
setCurrentSection(sec);
|
const pathParts = location.pathname.split('/');
|
||||||
setSelectedStore(null);
|
const currentSection = (pathParts.includes('admin') ? pathParts[pathParts.length - 1] : 'dashboard') as MainSection;
|
||||||
};
|
|
||||||
|
const [selectedStore, setSelectedStore] = useState<{ locationid?: number; name: string; zone: string; deliveries: number; sales: string; orders?: number; staff: string; color: string; status: string } | null>(null);
|
||||||
|
|
||||||
const [isCoimbatoreView, setIsCoimbatoreView] = useState(false);
|
const [isCoimbatoreView, setIsCoimbatoreView] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -474,31 +478,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live Sparkline Trend Histogram */}
|
|
||||||
<div className="mt-4 pt-3.5 border-t border-zinc-100">
|
|
||||||
<div className="flex justify-between items-center text-[8px] text-zinc-400 font-bold uppercase tracking-widest mb-1.5">
|
|
||||||
<span>Speed Index (Live Feed)</span>
|
|
||||||
<span className="text-[#662582] flex items-center gap-0.5 text-[8px] font-bold">
|
|
||||||
<Activity className="w-2.5 h-2.5 animate-pulse" /> Live
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-6 flex items-end gap-1">
|
|
||||||
{[30, 48, 25, 62, 54, 75, 42, 80, Math.min(95, Math.max(15, st.deliveries * 1.8))].map((val, idx) => (
|
|
||||||
<div key={idx} className="flex-1 bg-zinc-100 rounded-t-sm h-full relative overflow-hidden group/bar">
|
|
||||||
<div
|
|
||||||
style={{ height: `${val}%` }}
|
|
||||||
className={`absolute bottom-0 left-0 right-0 rounded-t-sm transition-all duration-500 ${
|
|
||||||
st.status.toLowerCase() === 'active'
|
|
||||||
? st.deliveries > 40
|
|
||||||
? 'bg-rose-500/80 group-hover/bar:bg-rose-500'
|
|
||||||
: 'bg-[#662582]/70 group-hover/bar:bg-[#662582]'
|
|
||||||
: 'bg-amber-500/70 group-hover/bar:bg-amber-500'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lead Manager Profile block */}
|
{/* Lead Manager Profile block */}
|
||||||
<div className="flex justify-between items-center text-xs text-zinc-650 bg-zinc-50/80 rounded-xl p-2.5 border border-zinc-100/80 mt-4">
|
<div className="flex justify-between items-center text-xs text-zinc-650 bg-zinc-50/80 rounded-xl p-2.5 border border-zinc-100/80 mt-4">
|
||||||
@@ -567,22 +547,28 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Auth gate ──────────────────────────────────────────────────────────────
|
// ── Auth gate ──────────────────────────────────────────────────────────────
|
||||||
// Signed out → login screen. User role → their single allocated store console
|
|
||||||
// (scoped by applocationid). Only the admin role reaches the full operations
|
|
||||||
// dashboard with the all-stores registry below.
|
|
||||||
if (authRole === null) {
|
if (authRole === null) {
|
||||||
return <LoginView onLogin={handleLogin} />;
|
return (
|
||||||
}
|
<Routes>
|
||||||
if (authRole === 'user' && authUser) {
|
<Route path="/login" element={<LoginView onLogin={handleLogin} />} />
|
||||||
return <UserStorePage onLogout={handleLogout} user={authUser} />;
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (authRole === 'user' && authUser) {
|
||||||
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased">
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/store/*" element={<UserStorePage onLogout={handleLogout} user={authUser} />} />
|
||||||
|
<Route path="*" element={<Navigate to="/store/console" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminConsole = (
|
||||||
|
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased overflow-x-hidden">
|
||||||
{/* Navbar segment */}
|
{/* Navbar segment */}
|
||||||
<Header
|
<Header
|
||||||
currentSection={currentSection}
|
|
||||||
setCurrentSection={handleSetSection}
|
|
||||||
isCoimbatoreView={isCoimbatoreView}
|
isCoimbatoreView={isCoimbatoreView}
|
||||||
onToggleSidebar={() => setSidebarOpen((prev) => !prev)}
|
onToggleSidebar={() => setSidebarOpen((prev) => !prev)}
|
||||||
isSidebarOpen={sidebarOpen}
|
isSidebarOpen={sidebarOpen}
|
||||||
@@ -610,60 +596,69 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Container workspace layout splits */}
|
{/* Main Container workspace layout splits */}
|
||||||
<div className="flex pt-16">
|
<div className="flex pt-16 w-full max-w-[100vw]">
|
||||||
|
|
||||||
{/* Interactive Left Rail */}
|
{/* Interactive Left Rail */}
|
||||||
<Sidebar
|
<Sidebar
|
||||||
currentSection={currentSection}
|
|
||||||
setCurrentSection={handleSetSection}
|
|
||||||
isCoimbatoreView={isCoimbatoreView}
|
isCoimbatoreView={isCoimbatoreView}
|
||||||
setIsCoimbatoreView={setIsCoimbatoreView}
|
setIsCoimbatoreView={setIsCoimbatoreView}
|
||||||
isOpen={sidebarOpen}
|
isOpen={sidebarOpen}
|
||||||
isAdmin={authRole === 'admin'}
|
isAdmin={authRole === 'admin'}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main core pages payload area */}
|
{/* Main core pages payload area */}
|
||||||
<main className={`flex-1 min-w-0 transition-all duration-300 ${sidebarOpen ? 'md:pl-64' : 'md:pl-20'} ${currentSection === 'inventory' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'}`}>
|
<main className={`flex-1 min-w-0 transition-all duration-300 ${sidebarOpen ? 'md:pl-64' : 'md:pl-20'} ${currentSection === 'inventory' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'}`}>
|
||||||
<div className={`w-full transition-all duration-300 ${currentSection === 'inventory' ? 'h-full p-4 md:p-6 overflow-hidden' : 'p-container-margin md:p-xl space-y-lg'}`}>
|
<div className={`w-full transition-all duration-300 ${currentSection === 'inventory' ? 'h-full p-4 md:p-6 overflow-hidden' : 'p-container-margin md:p-xl space-y-lg'}`}>
|
||||||
{/* Nav content routing */}
|
<Routes>
|
||||||
{currentSection === 'dashboard' && (
|
<Route index element={<Navigate to="dashboard" replace />} />
|
||||||
selectedStore ? (
|
<Route path="dashboard" element={
|
||||||
renderStoresSection()
|
selectedStore ? (
|
||||||
) : (
|
renderStoresSection()
|
||||||
<div className="space-y-6">
|
) : (
|
||||||
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} />
|
<div className="space-y-6">
|
||||||
<div>
|
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} />
|
||||||
{renderStoresSection()}
|
<div>
|
||||||
|
{renderStoresSection()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
} />
|
||||||
)}
|
|
||||||
|
|
||||||
{currentSection === 'inventory' && (
|
<Route path="inventory" element={
|
||||||
<InventoryView
|
<InventoryView
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
isCoimbatoreView={isCoimbatoreView}
|
isCoimbatoreView={isCoimbatoreView}
|
||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
/>
|
/>
|
||||||
)}
|
} />
|
||||||
|
|
||||||
{currentSection === 'reports' && (
|
<Route path="reports" element={
|
||||||
<ReportsView
|
<ReportsView
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
isCoimbatoreView={isCoimbatoreView}
|
isCoimbatoreView={isCoimbatoreView}
|
||||||
setIsCoimbatoreView={setIsCoimbatoreView}
|
setIsCoimbatoreView={setIsCoimbatoreView}
|
||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
/>
|
/>
|
||||||
)}
|
} />
|
||||||
|
|
||||||
|
<Route path="settings" element={
|
||||||
|
<SettingsView tenantId={tenantId} user={authUser ?? undefined} />
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Handle alternative sections: Settings */}
|
</Routes>
|
||||||
{['settings'].includes(currentSection) &&
|
|
||||||
renderSecondarySection()
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/*" element={AdminConsole} />
|
||||||
|
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
|
||||||
{/* CALENDAR SCHEDULER DIALOG MODAL */}
|
{/* CALENDAR SCHEDULER DIALOG MODAL */}
|
||||||
{showCalendarModal && (
|
{showCalendarModal && (
|
||||||
@@ -798,6 +793,6 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ComparisonModal />
|
<ComparisonModal />
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
|
|||||||
const res = await createLocationMut.mutateAsync({
|
const res = await createLocationMut.mutateAsync({
|
||||||
...storeForm,
|
...storeForm,
|
||||||
status: 'Active',
|
status: 'Active',
|
||||||
|
moduleid: 2,
|
||||||
|
applocationid: 1,
|
||||||
|
partnerid: 1,
|
||||||
|
roleid: 4,
|
||||||
});
|
});
|
||||||
setStoreSuccess(res);
|
setStoreSuccess(res);
|
||||||
alert('Store Location created successfully!');
|
alert('Store Location created successfully!');
|
||||||
|
|||||||
199
src/components/AdminCustomersView.tsx
Normal file
199
src/components/AdminCustomersView.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Users, Search, Phone, Mail, ChevronRight, Award, MapPin } from 'lucide-react';
|
||||||
|
import { useFiestaTenantCustomers, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||||
|
import { num as fnum, str as fstr } from '../services/fiestaApi';
|
||||||
|
|
||||||
|
interface AdminCustomersViewProps {
|
||||||
|
tenantId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminCustomersView({ tenantId }: AdminCustomersViewProps) {
|
||||||
|
const [customerSearch, setCustomerSearch] = useState('');
|
||||||
|
|
||||||
|
// Fetch all customers for the tenant by passing locationid: 0
|
||||||
|
const customersQ = useFiestaTenantCustomers({
|
||||||
|
tenantid: tenantId,
|
||||||
|
locationid: 0,
|
||||||
|
pagesize: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||||
|
const locations = locationsQ.data ?? [];
|
||||||
|
|
||||||
|
const getMergedCustomers = () => {
|
||||||
|
const rawCustomers = customersQ.data ?? [];
|
||||||
|
return rawCustomers.map((c: any) => {
|
||||||
|
const id =
|
||||||
|
fnum(c.customerid) ||
|
||||||
|
fnum(c.customerheaderid) ||
|
||||||
|
fnum(c.userid) ||
|
||||||
|
fnum(c.id) ||
|
||||||
|
null;
|
||||||
|
const spent = fnum(c.totalspent);
|
||||||
|
|
||||||
|
const locId = fnum(c.locationid) || fnum(c.applocationid);
|
||||||
|
const loc = locations.find((l: any) => fnum(l.locationid) === locId || fnum(l.applocationid) === locId);
|
||||||
|
const storeName = loc ? fstr(loc.locationname) : (fstr(c.locationname) || 'Central');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer',
|
||||||
|
phone: fstr(c.contactno) || '—',
|
||||||
|
email: fstr(c.email),
|
||||||
|
address: fstr(c.address) || 'Coimbatore',
|
||||||
|
ordersCount: Number(c.orderscount) || 0,
|
||||||
|
totalSpent: spent > 0 ? `₹${spent.toLocaleString('en-IN')}` : '—',
|
||||||
|
storeName,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const customersList = getMergedCustomers().filter(c =>
|
||||||
|
c.name.toLowerCase().includes(customerSearch.toLowerCase()) ||
|
||||||
|
c.phone.includes(customerSearch) ||
|
||||||
|
c.address.toLowerCase().includes(customerSearch.toLowerCase()) ||
|
||||||
|
c.storeName.toLowerCase().includes(customerSearch.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper for jewel-tone avatars
|
||||||
|
const toneFor = (str: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
const hues = [
|
||||||
|
{ soft: '#f3e8ff', fg: '#6b21a8' }, // purple
|
||||||
|
{ soft: '#e0e7ff', fg: '#3730a3' }, // indigo
|
||||||
|
{ soft: '#fce7f3', fg: '#9d174d' }, // pink
|
||||||
|
{ soft: '#ffedd5', fg: '#9a3412' }, // orange
|
||||||
|
{ soft: '#dcfce7', fg: '#166534' }, // green
|
||||||
|
{ soft: '#cffafe', fg: '#155e75' }, // cyan
|
||||||
|
];
|
||||||
|
return hues[Math.abs(hash) % hues.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialsOf = (name: string) => {
|
||||||
|
const p = name.split(' ').filter(Boolean);
|
||||||
|
return ((p[0]?.[0] || '') + (p.length > 1 ? p[p.length - 1][0] : '')).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const localityOf = (addr: string) => {
|
||||||
|
if (!addr) return '';
|
||||||
|
const p = addr.split(',');
|
||||||
|
return p.length > 1 ? p[p.length - 2].trim() : p[0].trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between md:items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-black text-[#0f172a] flex items-center gap-2">
|
||||||
|
<Users size={26} className="text-[#662582]" />
|
||||||
|
Customers
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full md:w-72 shrink-0">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#662582]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search customers, stores..."
|
||||||
|
value={customerSearch}
|
||||||
|
onChange={(e) => setCustomerSearch(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2.5 border border-[#662582]/20 rounded-xl text-[13px] text-[#0f172a] placeholder:text-zinc-400 outline-none bg-white focus:border-[#662582] focus:ring-4 focus:ring-[#662582]/10 transition-all shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customersQ.isLoading ? (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center space-y-4">
|
||||||
|
<div className="relative w-10 h-10">
|
||||||
|
<span className="absolute inset-0 rounded-full border-2 border-purple-100" />
|
||||||
|
<span className="absolute inset-0 rounded-full border-2 border-t-purple-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Loading Customers...</p>
|
||||||
|
</div>
|
||||||
|
) : customersList.length === 0 ? (
|
||||||
|
<div className="bg-white border border-[#e2e8f0] rounded-3xl py-24 flex flex-col items-center gap-4 text-center px-6 shadow-sm">
|
||||||
|
<span className="flex items-center justify-center w-16 h-16 rounded-2xl bg-purple-50 text-purple-700 shadow-sm"><Users size={28} /></span>
|
||||||
|
<p className="text-lg font-bold text-[#0f172a]">No customers found</p>
|
||||||
|
<p className="text-sm text-zinc-500 max-w-[350px] leading-relaxed">
|
||||||
|
{customerSearch ? 'Nothing matches your search criteria. Try a different term.' : 'There are no customers registered in the tenant yet.'}
|
||||||
|
</p>
|
||||||
|
{customerSearch && (
|
||||||
|
<button onClick={() => setCustomerSearch('')} className="mt-2 text-[13px] font-bold text-purple-700 bg-purple-50 hover:bg-purple-100 px-4 py-2 rounded-lg transition-colors cursor-pointer">
|
||||||
|
Clear Search
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{customersList.map((c: any, idx: number) => {
|
||||||
|
const tone = toneFor(c.name || `c${idx}`);
|
||||||
|
const locality = localityOf(c.address);
|
||||||
|
return (
|
||||||
|
<div key={c.id ?? idx} className="group bg-white border border-[#e2e8f0] rounded-2xl p-5 hover:shadow-lg hover:border-purple-300 transition-all duration-300 flex flex-col">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span
|
||||||
|
className="w-12 h-12 rounded-2xl flex items-center justify-center font-bold text-sm shrink-0 border border-white"
|
||||||
|
style={{ background: tone.soft, color: tone.fg }}
|
||||||
|
>
|
||||||
|
{initialsOf(c.name)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-extrabold text-[#0f172a] text-[15px] truncate" title={c.name}>{c.name}</h3>
|
||||||
|
{locality && (
|
||||||
|
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1 font-medium truncate" title={locality}>
|
||||||
|
<MapPin size={10} className="shrink-0 text-zinc-400" /> {locality}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{c.ordersCount > 5 && (
|
||||||
|
<span className="shrink-0 ml-2 px-2 py-1 bg-amber-50 text-amber-700 text-[9px] font-black uppercase tracking-widest rounded-lg border border-amber-200 flex items-center gap-1">
|
||||||
|
<Award size={10} /> Loyal
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
|
<div className="bg-slate-50 rounded-xl p-3 border border-slate-100">
|
||||||
|
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest block">Orders</span>
|
||||||
|
<span className="font-black text-[16px] text-[#0f172a] mt-0.5 block">{c.ordersCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-50/50 rounded-xl p-3 border border-emerald-100/50">
|
||||||
|
<span className="text-[9px] font-bold text-emerald-600/70 uppercase tracking-widest block">Gross Spend</span>
|
||||||
|
<span className="font-black text-[16px] text-emerald-700 mt-0.5 block">{c.totalSpent}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-5 flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-600 font-medium">
|
||||||
|
<div className="w-6 h-6 rounded-lg bg-slate-100 flex items-center justify-center shrink-0"><Phone size={12} className="text-slate-400" /></div>
|
||||||
|
<span className="tabular-nums">{c.phone}</span>
|
||||||
|
</div>
|
||||||
|
{c.email && c.email !== '—' && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-600 font-medium">
|
||||||
|
<div className="w-6 h-6 rounded-lg bg-slate-100 flex items-center justify-center shrink-0"><Mail size={12} className="text-slate-400" /></div>
|
||||||
|
<span className="truncate" title={c.email}>{c.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-slate-100 flex items-center justify-between gap-3 mt-auto">
|
||||||
|
<span className="text-xs font-bold text-purple-700 bg-purple-50 px-3 py-1.5 rounded-lg truncate w-full text-center">
|
||||||
|
{c.storeName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/components/ConfirmModal.tsx
Normal file
90
src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AlertTriangle, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: React.ReactNode;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isDestructive?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmModal({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
isDestructive = false,
|
||||||
|
isLoading = false,
|
||||||
|
}: ConfirmModalProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-4 animate-in fade-in duration-200"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget && !isLoading) onCancel(); }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white border border-[#e2e8f0] rounded-xl w-[90vw] sm:w-[400px] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="p-4 sm:p-5 border-b border-[#e2e8f0] bg-slate-50/50 flex justify-between items-center shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isDestructive && <AlertTriangle size={18} className="text-red-500" />}
|
||||||
|
<h2 className="text-base font-bold text-slate-800 uppercase tracking-wide">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="text-slate-400 hover:text-slate-600 hover:bg-slate-100 p-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 overflow-y-auto">
|
||||||
|
<div className="text-sm text-slate-600 leading-relaxed">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-50/80 border-t border-slate-100 flex justify-end gap-3 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 text-sm font-semibold text-slate-600 hover:text-slate-800 hover:bg-slate-200 bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`px-4 py-2 text-sm font-semibold rounded-lg transition-colors flex items-center justify-center min-w-[90px] disabled:opacity-50 ${
|
||||||
|
isDestructive
|
||||||
|
? 'bg-red-600 hover:bg-red-700 text-white shadow-sm shadow-red-200'
|
||||||
|
: 'bg-purple-600 hover:bg-purple-700 text-white shadow-sm shadow-purple-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
confirmText
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -490,7 +490,7 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
|||||||
|
|
||||||
// ── Riders Summary (per rider) ───────────────────────────────────────────────────
|
// ── Riders Summary (per rider) ───────────────────────────────────────────────────
|
||||||
function RidersSummaryReport({ fromdate, todate, tenantId, locationid }: { fromdate: string; todate: string; tenantId: number; locationid?: number }) {
|
function RidersSummaryReport({ fromdate, todate, tenantId, locationid }: { fromdate: string; todate: string; tenantId: number; locationid?: number }) {
|
||||||
const q = useFiestaFleetSummary({ tenantid: tenantId, fromdate, todate, applocationid: locationid });
|
const q = useFiestaFleetSummary({ tenantid: tenantId, fromdate, todate });
|
||||||
const rows = q.data ?? [];
|
const rows = q.data ?? [];
|
||||||
const mapped = rows.map((r) => ({
|
const mapped = rows.map((r) => ({
|
||||||
name: fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() || fstr(r.username) || `Rider ${fstr(r.userid)}`,
|
name: fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() || fstr(r.username) || `Rider ${fstr(r.userid)}`,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
useFiestaProductCategories,
|
useFiestaProductCategories,
|
||||||
useFiestaMasterCatalog,
|
useFiestaMasterCatalog,
|
||||||
useFiestaUpdateStockRequest,
|
useFiestaUpdateStockRequest,
|
||||||
|
useFiestaGetStockRequests,
|
||||||
} from '../services/fiestaQueries';
|
} from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
|
||||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||||
@@ -51,7 +52,6 @@ import AwaitingApi from './AwaitingApi';
|
|||||||
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
|
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
|
||||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||||
import { useCompare } from '../contexts/CompareContext';
|
import { useCompare } from '../contexts/CompareContext';
|
||||||
import TrialBatchDrawer, { TrialProduct } from './TrialBatchDrawer';
|
|
||||||
|
|
||||||
const MOCK_GLOBAL_CATALOG: ProductMatrixItem[] = [
|
const MOCK_GLOBAL_CATALOG: ProductMatrixItem[] = [
|
||||||
{
|
{
|
||||||
@@ -122,7 +122,7 @@ export default function InventoryView({
|
|||||||
tenantId = FIESTA_TENANT_ID
|
tenantId = FIESTA_TENANT_ID
|
||||||
}: InventoryViewProps) {
|
}: InventoryViewProps) {
|
||||||
const { selectedProducts, toggleProduct, setIsComparing, clearSelection, setHideCompareBar } = useCompare();
|
const { selectedProducts, toggleProduct, setIsComparing, clearSelection, setHideCompareBar } = useCompare();
|
||||||
const [trialProducts, setTrialProducts] = useState<TrialProduct[]>([]);
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
||||||
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped
|
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped
|
||||||
@@ -185,99 +185,70 @@ export default function InventoryView({
|
|||||||
const [requestStoreFilter, setRequestStoreFilter] = useState('All Stores');
|
const [requestStoreFilter, setRequestStoreFilter] = useState('All Stores');
|
||||||
|
|
||||||
const [storeRequests, setStoreRequests] = useState<{ locationid: number, locationname: string, picks: Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled'; requestedAt: string; resolvedAt?: string }> }[]>([]);
|
const [storeRequests, setStoreRequests] = useState<{ locationid: number, locationname: string, picks: Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled'; requestedAt: string; resolvedAt?: string }> }[]>([]);
|
||||||
|
const stockRequestsQ = useFiestaGetStockRequests({ tenantid: tenantId, pagesize: 1000 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'requests') {
|
if (activeTab === 'requests' && stockRequestsQ.data) {
|
||||||
const reqs: any[] = [];
|
const reqsMap: Record<number, any> = {};
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
stockRequestsQ.data.forEach((req: any) => {
|
||||||
const key = localStorage.key(i);
|
if (!reqsMap[req.locationid]) {
|
||||||
if (key && key.startsWith('nearledaily.catalogue.request.')) {
|
const loc = locations.find(l => l.locationid === req.locationid);
|
||||||
const locIdStr = key.split('.').pop();
|
reqsMap[req.locationid] = {
|
||||||
if (locIdStr && locIdStr !== 'na') {
|
locationid: req.locationid,
|
||||||
const locId = parseInt(locIdStr);
|
locationname: loc ? loc.locationname : `Outlet #${req.locationid}`,
|
||||||
const raw = localStorage.getItem(key);
|
picks: {}
|
||||||
if (raw) {
|
};
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
const picks: Record<string, any> = {};
|
|
||||||
for (const [k, v] of Object.entries(parsed)) {
|
|
||||||
if (typeof v === 'number') picks[k] = { qty: v, status: 'Pending', requestedAt: new Date().toISOString() };
|
|
||||||
else {
|
|
||||||
picks[k] = v;
|
|
||||||
if (picks[k].status === 'Approve') picks[k].status = 'Approved';
|
|
||||||
if (picks[k].status === 'Reject') picks[k].status = 'Rejected';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(picks).length > 0) {
|
|
||||||
const loc = locations.find(l => l.locationid === locId);
|
|
||||||
reqs.push({
|
|
||||||
locationid: locId,
|
|
||||||
locationname: loc ? loc.locationname : `Outlet #${locId}`,
|
|
||||||
picks
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if (!reqsMap[req.locationid].picks[req.productid]) {
|
||||||
setStoreRequests(reqs);
|
reqsMap[req.locationid].picks[req.productid] = {
|
||||||
|
qty: req.qty,
|
||||||
|
status: req.status,
|
||||||
|
requestedAt: req.created,
|
||||||
|
resolvedAt: req.updated,
|
||||||
|
productname: req.productname,
|
||||||
|
productimage: req.productimage,
|
||||||
|
requestid: req.requestid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setStoreRequests(Object.values(reqsMap).filter(r => Object.keys(r.picks).length > 0));
|
||||||
}
|
}
|
||||||
}, [activeTab, locations]);
|
}, [activeTab, locations, stockRequestsQ.data]);
|
||||||
|
|
||||||
const updateStockRequestMutation = useFiestaUpdateStockRequest();
|
const updateStockRequestMutation = useFiestaUpdateStockRequest();
|
||||||
|
|
||||||
const updateProductRequestStatus = (locationid: number, productid: string, status: 'Approved' | 'Rejected' | 'Pending') => {
|
const updateProductRequestStatus = (locationid: number, productid: string, status: 'Approved' | 'Rejected' | 'Pending') => {
|
||||||
// Optimistic API update
|
// Find the requestid from the current state
|
||||||
|
let requestid = 0;
|
||||||
|
storeRequests.forEach(r => {
|
||||||
|
if (r.locationid === locationid && r.picks[productid]) {
|
||||||
|
requestid = r.picks[productid].requestid;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!requestid) return;
|
||||||
|
|
||||||
updateStockRequestMutation.mutate({
|
updateStockRequestMutation.mutate({
|
||||||
tenantid: tenantId,
|
tenantid: tenantId,
|
||||||
locationid,
|
locationid,
|
||||||
productid: Number(productid),
|
productid: Number(productid),
|
||||||
|
requestid,
|
||||||
status
|
status
|
||||||
});
|
});
|
||||||
|
|
||||||
const key = `nearledaily.catalogue.request.${locationid}`;
|
// Optimistic UI update
|
||||||
const raw = localStorage.getItem(key);
|
setStoreRequests(prev => prev.map(r => {
|
||||||
if (raw) {
|
if (r.locationid === locationid && r.picks[productid]) {
|
||||||
try {
|
return {
|
||||||
const picks = JSON.parse(raw);
|
...r,
|
||||||
if (picks[productid] != null) {
|
picks: {
|
||||||
const existing = typeof picks[productid] === 'number'
|
...r.picks,
|
||||||
? { qty: picks[productid], status: 'Pending', requestedAt: new Date().toISOString() }
|
[productid]: { ...r.picks[productid], status, resolvedAt: new Date().toISOString() }
|
||||||
: picks[productid];
|
|
||||||
|
|
||||||
picks[productid] = { ...existing, status, resolvedAt: status === 'Pending' ? undefined : new Date().toISOString() };
|
|
||||||
localStorage.setItem(key, JSON.stringify(picks));
|
|
||||||
|
|
||||||
if (status === 'Approved') {
|
|
||||||
const prod = products.find(p => String(p.id) === String(productid)) || MOCK_GLOBAL_CATALOG.find(p => String(p.id) === String(productid));
|
|
||||||
if (prod) {
|
|
||||||
if (storeCat.has(productid)) {
|
|
||||||
storeCat.setQty(productid, storeCat.getQty(productid) + existing.qty);
|
|
||||||
} else {
|
|
||||||
storeCat.add({
|
|
||||||
productid: String(prod.id),
|
|
||||||
name: prod.name,
|
|
||||||
image: prod.image,
|
|
||||||
category: prod.category,
|
|
||||||
sku: prod.sku,
|
|
||||||
price: prod.unitsSold > 0 ? Math.round(prod.revenue / prod.unitsSold) : 10,
|
|
||||||
unit: prod.exposure || 'Piece',
|
|
||||||
qty: existing.qty
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
setStoreRequests(prev => prev.map(r => {
|
}
|
||||||
if (r.locationid === locationid) {
|
return r;
|
||||||
return { ...r, picks: { ...r.picks, [productid]: picks[productid] } };
|
}));
|
||||||
}
|
|
||||||
return r;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hide compare bar when not in Global Catalogue
|
// Hide compare bar when not in Global Catalogue
|
||||||
@@ -1020,22 +991,6 @@ export default function InventoryView({
|
|||||||
<Plus size={14} /> Import to Catalogue
|
<Plus size={14} /> Import to Catalogue
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setTrialProducts([{
|
|
||||||
id: prod.id,
|
|
||||||
name: prod.name,
|
|
||||||
sku: prod.sku,
|
|
||||||
category: prod.category.split(' / ')[0],
|
|
||||||
price: prod.revenue / Math.max(1, prod.unitsSold),
|
|
||||||
image: prod.image
|
|
||||||
}]);
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center justify-center gap-1.5 py-2 rounded-xl text-[11px] font-bold transition-all bg-indigo-50 text-indigo-700 hover:bg-indigo-600 hover:text-white cursor-pointer border border-indigo-100 hover:border-indigo-600"
|
|
||||||
>
|
|
||||||
<Package size={14} /> Request Sample
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1185,22 +1140,6 @@ export default function InventoryView({
|
|||||||
<Plus size={14} /> Import to Catalogue
|
<Plus size={14} /> Import to Catalogue
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setTrialProducts([{
|
|
||||||
id: prod.id,
|
|
||||||
name: prod.name,
|
|
||||||
sku: prod.sku,
|
|
||||||
category: prod.category.split(' / ')[0],
|
|
||||||
price: prod.revenue / Math.max(1, prod.unitsSold),
|
|
||||||
image: prod.image
|
|
||||||
}]);
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center justify-center gap-1.5 py-2 rounded-xl text-[11px] font-bold transition-all bg-indigo-50 text-indigo-700 hover:bg-indigo-600 hover:text-white cursor-pointer border border-indigo-100 hover:border-indigo-600"
|
|
||||||
>
|
|
||||||
<Package size={14} /> Request Sample
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1263,7 +1202,7 @@ export default function InventoryView({
|
|||||||
<Inbox size={32} className="text-slate-400" />
|
<Inbox size={32} className="text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-slate-700 tracking-tight">No Pending Requests</h3>
|
<h3 className="text-xl font-bold text-slate-700 tracking-tight">No Pending Requests</h3>
|
||||||
<p className="text-sm font-medium text-slate-400 mt-2 text-center max-w-sm leading-relaxed">
|
<p className="text-sm font-medium text-slate-400 mt-2 text-center w-full max-w-[400px] leading-relaxed">
|
||||||
You're all caught up! All store stock requests have been processed and there is currently no pending action required.
|
You're all caught up! All store stock requests have been processed and there is currently no pending action required.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1505,22 +1444,6 @@ export default function InventoryView({
|
|||||||
>
|
>
|
||||||
<Plus size={18} strokeWidth={2} /> Import Local
|
<Plus size={18} strokeWidth={2} /> Import Local
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setTrialProducts([{
|
|
||||||
id: selectedAdminProduct.id,
|
|
||||||
name: selectedAdminProduct.name,
|
|
||||||
sku: selectedAdminProduct.sku,
|
|
||||||
category: String(selectedAdminProduct.category || '').split(' / ')[0],
|
|
||||||
price: selectedAdminProduct.revenue / Math.max(1, selectedAdminProduct.unitsSold),
|
|
||||||
image: selectedAdminProduct.image
|
|
||||||
}]);
|
|
||||||
}}
|
|
||||||
className="w-auto px-6 flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-semibold transition-colors bg-white text-slate-700 border border-slate-200 hover:bg-slate-50"
|
|
||||||
title="Request Sample"
|
|
||||||
>
|
|
||||||
<Package size={18} strokeWidth={2} /> Sample
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1661,23 +1584,6 @@ export default function InventoryView({
|
|||||||
})()}
|
})()}
|
||||||
</SlideDrawer>
|
</SlideDrawer>
|
||||||
|
|
||||||
<TrialBatchDrawer
|
|
||||||
isOpen={trialProducts.length > 0}
|
|
||||||
onClose={() => setTrialProducts([])}
|
|
||||||
product={trialProducts[0] || null}
|
|
||||||
tenantId={tenantId}
|
|
||||||
onSuccess={(tp) => {
|
|
||||||
const globalProd = MOCK_GLOBAL_CATALOG.find(p => p.id === tp.id);
|
|
||||||
if (globalProd) {
|
|
||||||
setProducts(prev => {
|
|
||||||
if (prev.some(p => p.id === globalProd.id)) return prev;
|
|
||||||
return [{ ...globalProd, isSample: true, isNew: true }, ...prev];
|
|
||||||
});
|
|
||||||
setActiveTab('catalog');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── Floating Import Button ── */}
|
{/* ── Floating Import Button ── */}
|
||||||
{activeTab === 'import_branding' && selectedProducts.length > 0 && (
|
{activeTab === 'import_branding' && selectedProducts.length > 0 && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'S
|
|||||||
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) {
|
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) {
|
||||||
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
|
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
|
||||||
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
|
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
|
||||||
|
const [activeStoreId, setActiveStoreId] = useState<number | 'all'>('all');
|
||||||
const [stockFilter, setStockFilter] = useState<'All' | 'Healthy' | 'Low Stock' | 'Critical'>('All');
|
const [stockFilter, setStockFilter] = useState<'All' | 'Healthy' | 'Low Stock' | 'Critical'>('All');
|
||||||
const [selectedCell, setSelectedCell] = useState<{ day: string; hour: string; val: number } | null>(null);
|
const [selectedCell, setSelectedCell] = useState<{ day: string; hour: string; val: number } | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -105,7 +106,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
|
|
||||||
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
||||||
|
|
||||||
const filteredLocations = useMemo(() => {
|
const regionLocations = useMemo(() => {
|
||||||
const rawLocations = [...(locSummaryQ.data ?? [])];
|
const rawLocations = [...(locSummaryQ.data ?? [])];
|
||||||
if (selectedRegion === 'coimbatore') {
|
if (selectedRegion === 'coimbatore') {
|
||||||
return rawLocations.filter(r => isCoimbatoreNode(r.locationname || ''));
|
return rawLocations.filter(r => isCoimbatoreNode(r.locationname || ''));
|
||||||
@@ -116,6 +117,15 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
return rawLocations;
|
return rawLocations;
|
||||||
}, [locSummaryQ.data, selectedRegion]);
|
}, [locSummaryQ.data, selectedRegion]);
|
||||||
|
|
||||||
|
const effectiveStoreId = activeTab === 'sales_revenue' ? activeStoreId : 'all';
|
||||||
|
|
||||||
|
const filteredLocations = useMemo(() => {
|
||||||
|
if (effectiveStoreId !== 'all') {
|
||||||
|
return regionLocations.filter(r => Number(r.locationid) === effectiveStoreId);
|
||||||
|
}
|
||||||
|
return regionLocations;
|
||||||
|
}, [regionLocations, effectiveStoreId]);
|
||||||
|
|
||||||
const mappedLocations = useMemo(() => {
|
const mappedLocations = useMemo(() => {
|
||||||
return filteredLocations.map(l => ({ locationid: Number(l.locationid), locationname: l.locationname || '' }));
|
return filteredLocations.map(l => ({ locationid: Number(l.locationid), locationname: l.locationname || '' }));
|
||||||
}, [filteredLocations]);
|
}, [filteredLocations]);
|
||||||
@@ -139,10 +149,10 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
return ((current - previous) / previous) * 100;
|
return ((current - previous) / previous) * 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAllRegions = selectedRegion === 'all';
|
const isGlobalView = selectedRegion === 'all' && effectiveStoreId === 'all';
|
||||||
const ordersDelta = (s && prevS && isAllRegions) ? pctChange(s.total, prevS.total) : null;
|
const ordersDelta = (s && prevS && isGlobalView) ? pctChange(s.total, prevS.total) : null;
|
||||||
const cancelledDelta = (s && prevS && isAllRegions) ? pctChange(s.cancelled, prevS.cancelled) : null;
|
const cancelledDelta = (s && prevS && isGlobalView) ? pctChange(s.cancelled, prevS.cancelled) : null;
|
||||||
const revenueDelta = (revS && prevRevS && isAllRegions) ? pctChange(revS.grossrevenue, prevRevS.grossrevenue) : null;
|
const revenueDelta = (revS && prevRevS && isGlobalView) ? pctChange(revS.grossrevenue, prevRevS.grossrevenue) : null;
|
||||||
|
|
||||||
const fmtDelta = (d: number) => `${d >= 0 ? '+' : ''}${d.toFixed(1)}%`;
|
const fmtDelta = (d: number) => `${d >= 0 ? '+' : ''}${d.toFixed(1)}%`;
|
||||||
|
|
||||||
@@ -204,7 +214,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
let avgOrderVal = 0;
|
let avgOrderVal = 0;
|
||||||
let activeSkusVal = 0;
|
let activeSkusVal = 0;
|
||||||
|
|
||||||
if (isAllRegions) {
|
if (isGlobalView) {
|
||||||
totalOrdersVal = s?.total ?? 0;
|
totalOrdersVal = s?.total ?? 0;
|
||||||
deliveredVal = s?.delivered ?? 0;
|
deliveredVal = s?.delivered ?? 0;
|
||||||
cancelledVal = s?.cancelled ?? 0;
|
cancelledVal = s?.cancelled ?? 0;
|
||||||
@@ -257,7 +267,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
isPositive: revenueDelta === null ? true : revenueDelta >= 0,
|
isPositive: revenueDelta === null ? true : revenueDelta >= 0,
|
||||||
spark: [20, 30, 25, 45, 40, 55, 50, 68],
|
spark: [20, 30, 25, 45, 40, 55, 50, 68],
|
||||||
color: 'emerald',
|
color: 'emerald',
|
||||||
awaiting: !isAllRegions,
|
awaiting: !isGlobalView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cancelled' as const,
|
id: 'cancelled' as const,
|
||||||
@@ -456,7 +466,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
);
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
const data = tsQ_all.data || [];
|
const data = isGlobalView ? (tsQ_all.data || []) : (tsQ_stores.data || []);
|
||||||
if (data.length === 0) return [{ label: 'No Data', value: 0 }];
|
if (data.length === 0) return [{ label: 'No Data', value: 0 }];
|
||||||
|
|
||||||
return data.map((d: any) => {
|
return data.map((d: any) => {
|
||||||
@@ -483,25 +493,46 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
value: val
|
value: val
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [tsQ_all.data, tsQ_stores.data, isAllRegions, chartMetric, tsGranularity]);
|
}, [tsQ_all.data, tsQ_stores.data, isGlobalView, chartMetric, tsGranularity]);
|
||||||
|
|
||||||
const maxChartVal = useMemo(() => Math.max(...chartData.map(d => d.value), 1), [chartData]);
|
const maxChartVal = useMemo(() => Math.max(...chartData.map(d => d.value), 1), [chartData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-lg animate-in fade-in duration-500 font-sans">
|
<div className="space-y-lg animate-in fade-in duration-500 font-sans">
|
||||||
<div className="flex items-center gap-4 border-b border-slate-200/70 pb-2 -mt-2 relative z-20">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 relative z-20 -mt-2 pb-2 border-b border-slate-200/70">
|
||||||
<button
|
<div className="flex items-center gap-4">
|
||||||
onClick={() => setActiveTab('overview')}
|
<button
|
||||||
className={`pb-2 text-sm font-bold border-b-2 transition-colors ${activeTab === 'overview' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
onClick={() => setActiveTab('overview')}
|
||||||
>
|
className={`pb-1 text-sm font-bold border-b-2 transition-colors ${activeTab === 'overview' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||||
Overview
|
>
|
||||||
</button>
|
Overview
|
||||||
<button
|
</button>
|
||||||
onClick={() => setActiveTab('sales_revenue')}
|
<button
|
||||||
className={`pb-2 text-sm font-bold border-b-2 transition-colors ${activeTab === 'sales_revenue' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
onClick={() => setActiveTab('sales_revenue')}
|
||||||
>
|
className={`pb-1 text-sm font-bold border-b-2 transition-colors ${activeTab === 'sales_revenue' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||||
Sales & Revenue
|
>
|
||||||
</button>
|
Sales & Revenue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Store Filter */}
|
||||||
|
{activeTab === 'sales_revenue' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Store size={16} className="text-slate-500" />
|
||||||
|
<select
|
||||||
|
value={activeStoreId}
|
||||||
|
onChange={(e) => setActiveStoreId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||||||
|
className="text-sm font-bold outline-none rounded-lg px-3 py-1 border border-slate-200 bg-white text-slate-700 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="all">All Stores</option>
|
||||||
|
{regionLocations.map(loc => (
|
||||||
|
<option key={loc.locationid} value={Number(loc.locationid)}>
|
||||||
|
{loc.locationname || `Store ${loc.locationid}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'overview' ? (
|
{activeTab === 'overview' ? (
|
||||||
@@ -1096,7 +1127,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<SalesRevenueReport tenantId={tenantId} />
|
<SalesRevenueReport tenantId={tenantId} locationid={activeStoreId === 'all' ? undefined : Number(activeStoreId)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,55 +17,11 @@ import {
|
|||||||
TrendingDown
|
TrendingDown
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useFiestaAllOrders
|
useFiestaAllOrders,
|
||||||
|
useFiestaSalesSummary
|
||||||
} from '../services/fiestaQueries';
|
} from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
|
|
||||||
const generateMockOrders = (from: string, to: string, locationid?: number): Row[] => {
|
|
||||||
const fromTime = new Date(from).getTime();
|
|
||||||
const toTime = new Date(to).getTime();
|
|
||||||
const mockOrders: Row[] = [];
|
|
||||||
|
|
||||||
const locations = [
|
|
||||||
{ id: 1166, name: 'Main Hub' },
|
|
||||||
{ id: 1167, name: 'Downtown Branch' },
|
|
||||||
{ id: 1168, name: 'Westside Market' }
|
|
||||||
];
|
|
||||||
const paymentModes = ['CASH', 'UPI', 'UPI', 'CARD'];
|
|
||||||
const statuses = ['delivered', 'delivered', 'delivered', 'completed', 'cancelled'];
|
|
||||||
const customers = ['John Doe', 'Jane Smith', 'Acme Corp', 'Bob Johnson', 'Local Cafe', 'John Doe'];
|
|
||||||
|
|
||||||
// Seeded random for stable mock data across renders
|
|
||||||
let seed = 12345;
|
|
||||||
const random = () => {
|
|
||||||
seed = (seed * 9301 + 49297) % 233280;
|
|
||||||
return seed / 233280;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < 65; i++) {
|
|
||||||
const loc = locationid
|
|
||||||
? locations.find(l => l.id === locationid) || { id: locationid, name: `Store ${locationid}` }
|
|
||||||
: locations[Math.floor(random() * locations.length)];
|
|
||||||
const time = new Date(fromTime + random() * (toTime - fromTime + 86400000));
|
|
||||||
|
|
||||||
// Force peaks around lunch and dinner
|
|
||||||
if (random() > 0.5) time.setHours(11 + Math.floor(random() * 3)); // 11am-2pm
|
|
||||||
else time.setHours(17 + Math.floor(random() * 4)); // 5pm-9pm
|
|
||||||
|
|
||||||
mockOrders.push({
|
|
||||||
orderid: `ORD-MOCK-${8400 + i}`,
|
|
||||||
orderdate: time.toISOString(),
|
|
||||||
orderstatus: statuses[Math.floor(random() * statuses.length)],
|
|
||||||
ordervalue: Math.floor(random() * 2500) + 150,
|
|
||||||
quantity: Math.floor(random() * 8) + 1,
|
|
||||||
paymentmode: paymentModes[Math.floor(random() * paymentModes.length)],
|
|
||||||
locationid: loc.id,
|
|
||||||
applocation: loc.name,
|
|
||||||
deliverycustomer: customers[Math.floor(random() * customers.length)],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return mockOrders;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ... inside the component ...
|
// ... inside the component ...
|
||||||
import { shortTime } from '../services/fiestaMappers';
|
import { shortTime } from '../services/fiestaMappers';
|
||||||
@@ -108,12 +64,19 @@ export default function SalesRevenueReport({
|
|||||||
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
||||||
|
|
||||||
// ── Queries ──────────────────────────────────────────────────────────────────
|
// ── Queries ──────────────────────────────────────────────────────────────────
|
||||||
|
const salesSummaryQ = useFiestaSalesSummary({ tenantid: tenantId, fromdate, todate, locationid });
|
||||||
const allOrdersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate, todate, locationid });
|
const allOrdersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate, todate, locationid });
|
||||||
const mockData = useMemo(() => generateMockOrders(fromdate, todate, locationid), [fromdate, todate, locationid]);
|
const allRows = allOrdersQ.data || [];
|
||||||
const allRows = allOrdersQ.data && allOrdersQ.data.length > 0 ? allOrdersQ.data : mockData;
|
|
||||||
|
|
||||||
// ── Calculations ─────────────────────────────────────────────────────────────
|
// ── Calculations ─────────────────────────────────────────────────────────────
|
||||||
// Filter only delivered orders for actual sales/revenue calculation
|
const salesData = salesSummaryQ.data;
|
||||||
|
const totalRevenue = salesData?.totalRevenue ?? 0;
|
||||||
|
const totalOrders = salesData?.totalOrders ?? 0;
|
||||||
|
const aov = salesData?.averageOrderValue ?? 0;
|
||||||
|
const chartData = salesData?.chartData ?? [];
|
||||||
|
const backendTopLocations = salesData?.topLocations ?? [];
|
||||||
|
|
||||||
|
// Filter only delivered orders for client-side stats (retention, hourly, etc.)
|
||||||
const completedOrders = useMemo(() => {
|
const completedOrders = useMemo(() => {
|
||||||
return allRows.filter((r) => {
|
return allRows.filter((r) => {
|
||||||
const s = fstr(r.orderstatus).toLowerCase();
|
const s = fstr(r.orderstatus).toLowerCase();
|
||||||
@@ -121,52 +84,6 @@ export default function SalesRevenueReport({
|
|||||||
});
|
});
|
||||||
}, [allRows]);
|
}, [allRows]);
|
||||||
|
|
||||||
const { totalRevenue, totalOrders } = useMemo(() => {
|
|
||||||
let rev = 0;
|
|
||||||
let ords = 0;
|
|
||||||
for (const r of completedOrders) {
|
|
||||||
if (locationid) {
|
|
||||||
const rLoc = fnum(r.locationid);
|
|
||||||
const rApp = fnum(r.applocationid);
|
|
||||||
if (rLoc !== locationid && rApp !== locationid) continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const amt = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
|
||||||
rev += amt;
|
|
||||||
ords += 1;
|
|
||||||
}
|
|
||||||
return { totalRevenue: rev, totalOrders: ords };
|
|
||||||
}, [completedOrders, locationid]);
|
|
||||||
|
|
||||||
const aov = totalOrders > 0 ? totalRevenue / totalOrders : 0;
|
|
||||||
|
|
||||||
// Chart data (Group by date)
|
|
||||||
const chartData = useMemo(() => {
|
|
||||||
const map = new Map<string, { date: string; revenue: number; orders: number }>();
|
|
||||||
|
|
||||||
for (const r of completedOrders) {
|
|
||||||
if (locationid) {
|
|
||||||
const rLoc = fnum(r.locationid);
|
|
||||||
const rApp = fnum(r.applocationid);
|
|
||||||
if (rLoc !== locationid && rApp !== locationid) continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateVal = fstr(r.orderdate) || fstr(r.deliverydate);
|
|
||||||
if (!dateVal) continue;
|
|
||||||
|
|
||||||
const key = dateVal.split('T')[0];
|
|
||||||
const ex = map.get(key) || { date: key, revenue: 0, orders: 0 };
|
|
||||||
|
|
||||||
const amt = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
|
||||||
ex.revenue += amt;
|
|
||||||
ex.orders += 1;
|
|
||||||
|
|
||||||
map.set(key, ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
}, [completedOrders, locationid]);
|
|
||||||
|
|
||||||
const formatLabel = (key: string) => {
|
const formatLabel = (key: string) => {
|
||||||
const d = new Date(key);
|
const d = new Date(key);
|
||||||
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
@@ -234,19 +151,9 @@ export default function SalesRevenueReport({
|
|||||||
}, [completedOrders, locationid]);
|
}, [completedOrders, locationid]);
|
||||||
|
|
||||||
// 4. Top Locations Leaderboard (Admin Only)
|
// 4. Top Locations Leaderboard (Admin Only)
|
||||||
const topLocations = useMemo(() => {
|
const topLocations = backendTopLocations.length > 0
|
||||||
if (locationid) return []; // Only for admin
|
? backendTopLocations.map(l => ({ name: l.locationname, revenue: l.revenue }))
|
||||||
const lmap = new Map<string, number>();
|
: [];
|
||||||
for (const r of completedOrders) {
|
|
||||||
const locStr = fstr(r.applocation) || fstr(r.locationname) || `Location ${fstr(r.locationid) || fstr(r.applocationid)}`;
|
|
||||||
const amt = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
|
||||||
lmap.set(locStr, (lmap.get(locStr) || 0) + amt);
|
|
||||||
}
|
|
||||||
return Array.from(lmap.entries())
|
|
||||||
.map(([name, revenue]) => ({ name, revenue }))
|
|
||||||
.sort((a, b) => b.revenue - a.revenue)
|
|
||||||
.slice(0, 5); // Top 5
|
|
||||||
}, [completedOrders, locationid]);
|
|
||||||
|
|
||||||
const kpis = [
|
const kpis = [
|
||||||
{ label: 'Total Sales', value: totalOrders.toLocaleString('en-IN'), color: '#6366f1', icon: <ShoppingBag size={20} />, badge: 'Completed orders' },
|
{ label: 'Total Sales', value: totalOrders.toLocaleString('en-IN'), color: '#6366f1', icon: <ShoppingBag size={20} />, badge: 'Completed orders' },
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Bike
|
Bike
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
|
import { useFiestaAllTenants, useFiestaTenantLocations, useFiestaUpdateLocation, useFiestaDeleteLocation } from '../services/fiestaQueries';
|
||||||
import { useAppRoles } from '../services/queries';
|
import { useAppRoles } from '../services/queries';
|
||||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||||
import UsersPanel from './UsersPanel';
|
import UsersPanel from './UsersPanel';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
import AdminConsole from './AdminConsole';
|
import AdminConsole from './AdminConsole';
|
||||||
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
import type { AuthUser } from '../services/auth';
|
import type { AuthUser } from '../services/auth';
|
||||||
|
|
||||||
type TabKey = 'profile' | 'outlets' | 'users';
|
type TabKey = 'profile' | 'outlets' | 'users';
|
||||||
@@ -143,6 +144,50 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
// (see [R6]) so they are not persisted; the operational controls that would
|
// (see [R6]) so they are not persisted; the operational controls that would
|
||||||
// need persistence show an AwaitingApi notice instead of saving silently.
|
// need persistence show an AwaitingApi notice instead of saving silently.
|
||||||
const [form, setForm] = useState<MerchantSettings>({ ...DEFAULTS });
|
const [form, setForm] = useState<MerchantSettings>({ ...DEFAULTS });
|
||||||
|
|
||||||
|
const updateLocationMut = useFiestaUpdateLocation();
|
||||||
|
const deleteLocationMut = useFiestaDeleteLocation();
|
||||||
|
const [editingStore, setEditingStore] = useState<any>(null);
|
||||||
|
const [showEditStoreModal, setShowEditStoreModal] = useState(false);
|
||||||
|
const [confirmModalState, setConfirmModalState] = useState<{isOpen: boolean, loc: any}>({ isOpen: false, loc: null });
|
||||||
|
|
||||||
|
const handleUpdateStoreSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingStore) return;
|
||||||
|
try {
|
||||||
|
await updateLocationMut.mutateAsync({
|
||||||
|
tenantid: Number(tenantId),
|
||||||
|
locationid: Number(editingStore.locationid),
|
||||||
|
locationname: editingStore.locationname,
|
||||||
|
suburb: editingStore.suburb,
|
||||||
|
city: editingStore.city,
|
||||||
|
contactno: editingStore.contactno,
|
||||||
|
status: editingStore.status,
|
||||||
|
});
|
||||||
|
setShowEditStoreModal(false);
|
||||||
|
setEditingStore(null);
|
||||||
|
alert('Outlet updated successfully!');
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Failed to update outlet.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteStore = async (loc: any) => {
|
||||||
|
setConfirmModalState({ isOpen: true, loc });
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteOutlet = async () => {
|
||||||
|
const loc = confirmModalState.loc;
|
||||||
|
if (!loc) return;
|
||||||
|
try {
|
||||||
|
await deleteLocationMut.mutateAsync({ tenantid: tenantId, locationid: loc.locationid });
|
||||||
|
setConfirmModalState({ isOpen: false, loc: null });
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Failed to delete outlet.');
|
||||||
|
setConfirmModalState({ isOpen: false, loc: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [showStoreOnboarding, setShowStoreOnboarding] = useState(false);
|
const [showStoreOnboarding, setShowStoreOnboarding] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -210,11 +255,10 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
}, [rolesQ.data]);
|
}, [rolesQ.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-lg animate-in fade-in duration-300 relative font-sans text-slate-700">
|
<div className="space-y-3 font-sans text-slate-700">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="pb-4 border-b border-slate-100">
|
<div>
|
||||||
|
<div>
|
||||||
<div className="mt-3">
|
|
||||||
{tenantsQ.isLoading ? (
|
{tenantsQ.isLoading ? (
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-slate-400 uppercase tracking-wider">
|
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-slate-400 uppercase tracking-wider">
|
||||||
<span className="w-2 h-2 rounded-full bg-slate-350 animate-pulse" /> Loading store profile…
|
<span className="w-2 h-2 rounded-full bg-slate-350 animate-pulse" /> Loading store profile…
|
||||||
@@ -420,7 +464,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header: Outlet name & status */}
|
{/* Header: Outlet name & status */}
|
||||||
<div className="flex justify-between items-start gap-3">
|
<div className="flex justify-between items-start gap-3">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3 flex-1 min-w-0">
|
||||||
<div className="p-3 rounded-xl bg-purple-50 text-purple-650 shrink-0 self-start group-hover:bg-purple-100 transition-colors">
|
<div className="p-3 rounded-xl bg-purple-50 text-purple-650 shrink-0 self-start group-hover:bg-purple-100 transition-colors">
|
||||||
<Store size={20} />
|
<Store size={20} />
|
||||||
</div>
|
</div>
|
||||||
@@ -432,30 +476,43 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`shrink-0 px-2.5 py-1 rounded-full text-[9px] font-black uppercase tracking-wider ${
|
<div className="flex items-center justify-end gap-2 flex-wrap shrink-0">
|
||||||
loc.status.toLowerCase() === 'active'
|
<span className={`shrink-0 px-2.5 py-1 rounded-full text-[9px] font-black uppercase tracking-wider ${
|
||||||
? 'text-emerald-700 bg-emerald-50 border border-emerald-100/50'
|
loc.status.toLowerCase() === 'active'
|
||||||
: 'text-zinc-555 bg-zinc-100'
|
? 'text-emerald-700 bg-emerald-50 border border-emerald-100/50'
|
||||||
}`}>
|
: 'text-zinc-555 bg-zinc-100'
|
||||||
{loc.status || '—'}
|
}`}>
|
||||||
</span>
|
{loc.status || '—'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingStore({ ...loc });
|
||||||
|
setShowEditStoreModal(true);
|
||||||
|
}}
|
||||||
|
className="text-[10px] uppercase font-bold text-purple-600 hover:text-purple-800 hover:bg-purple-50 px-2 py-1 rounded border border-purple-200 transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteStore(loc)}
|
||||||
|
className="text-[10px] uppercase font-bold text-red-600 hover:text-red-800 hover:bg-red-50 px-2 py-1 rounded border border-red-200 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Outlet Details Grid */}
|
{/* Outlet Details Grid */}
|
||||||
<div className="grid grid-cols-2 gap-3 bg-slate-50/50 p-3.5 rounded-xl border border-slate-100/80">
|
<div className="grid grid-cols-1 gap-3 bg-slate-50/50 p-3.5 rounded-xl border border-slate-100/80">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-slate-455 uppercase font-bold block">Delivery Range</span>
|
<span className="text-[10px] text-slate-455 uppercase font-bold block">Delivery Range</span>
|
||||||
<p className="font-bold text-slate-700 text-xs">
|
<p className="font-bold text-slate-700 text-xs">
|
||||||
{loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'}
|
{loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 border-t border-slate-100 pt-2 mt-1">
|
||||||
<span className="text-[10px] text-slate-455 uppercase font-bold block">Delivery Speed</span>
|
|
||||||
<p className="font-bold text-slate-700 text-xs">
|
|
||||||
{loc.deliverymins ? `${loc.deliverymins} mins avg` : '—'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 col-span-2 border-t border-slate-100 pt-2 mt-1">
|
|
||||||
<span className="text-[10px] text-slate-455 uppercase font-bold block">Opening Hours</span>
|
<span className="text-[10px] text-slate-455 uppercase font-bold block">Opening Hours</span>
|
||||||
<p className="font-bold text-slate-750 text-xs flex items-center gap-1.5 mt-0.5">
|
<p className="font-bold text-slate-750 text-xs flex items-center gap-1.5 mt-0.5">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||||
@@ -483,6 +540,138 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* EDIT STORE MODAL */}
|
||||||
|
{showEditStoreModal && editingStore && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-4"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setShowEditStoreModal(false); }}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-[#e2e8f0] rounded-xl w-[90vw] sm:w-[450px] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="p-4 border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
|
||||||
|
<h4 className="font-bold text-[#0f172a] text-sm flex items-center gap-2">
|
||||||
|
<Store size={16} className="text-[#662582]" />
|
||||||
|
Edit Store Outlet Location
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEditStoreModal(false)}
|
||||||
|
className="text-zinc-400 hover:text-zinc-700 bg-zinc-100 hover:bg-zinc-200 p-1.5 rounded-lg transition-colors cursor-pointer border-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleUpdateStoreSubmit} className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div className="p-4 overflow-y-auto space-y-4">
|
||||||
|
<div className="bg-purple-50/50 border border-purple-100 rounded-lg p-3 flex items-start gap-3">
|
||||||
|
<Store className="text-purple-600 mt-0.5 shrink-0" size={16} />
|
||||||
|
<div>
|
||||||
|
<h5 className="font-bold text-[#0f172a] text-xs">Update Outlet Profile</h5>
|
||||||
|
<p className="text-zinc-500 text-[11px] leading-relaxed mt-0.5">
|
||||||
|
Adjust the operational metadata for this node. The outlet will instantly be updated across the delivery fleet system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">OUTLET NAME (*)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Coimbatore Main"
|
||||||
|
value={editingStore.locationname}
|
||||||
|
onChange={(e) => setEditingStore({ ...editingStore, locationname: e.target.value })}
|
||||||
|
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">LOCAL ZONE AREA (*)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. RS Puram"
|
||||||
|
value={editingStore.suburb}
|
||||||
|
onChange={(e) => setEditingStore({ ...editingStore, suburb: e.target.value })}
|
||||||
|
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">CITY</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Coimbatore"
|
||||||
|
value={editingStore.city}
|
||||||
|
onChange={(e) => setEditingStore({ ...editingStore, city: e.target.value })}
|
||||||
|
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">CONTACT PHONE (*)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. 9876543210"
|
||||||
|
value={editingStore.contactno}
|
||||||
|
onChange={(e) => setEditingStore({ ...editingStore, contactno: e.target.value })}
|
||||||
|
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">STATUS</label>
|
||||||
|
<select
|
||||||
|
value={editingStore.status}
|
||||||
|
onChange={(e) => setEditingStore({ ...editingStore, status: e.target.value })}
|
||||||
|
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
|
||||||
|
>
|
||||||
|
<option value="Active">Active</option>
|
||||||
|
<option value="Inactive">Inactive</option>
|
||||||
|
<option value="Suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-[#f1f5f9] flex justify-end gap-3 bg-[#f8fafc] shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEditStoreModal(false)}
|
||||||
|
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateLocationMut.isPending}
|
||||||
|
className="px-4 py-2 bg-[#662582] hover:bg-purple-800 text-white rounded-lg font-bold shadow-sm cursor-pointer border-none flex items-center gap-2 text-xs"
|
||||||
|
>
|
||||||
|
{updateLocationMut.isPending ? 'Updating...' : 'Update Outlet Profile'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmModalState.isOpen}
|
||||||
|
title="Delete Outlet"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
Are you sure you want to delete <strong>{confirmModalState.loc?.locationname}</strong>? This action cannot be undone.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText="Delete Outlet"
|
||||||
|
onConfirm={confirmDeleteOutlet}
|
||||||
|
onCancel={() => setConfirmModalState({ isOpen: false, loc: null })}
|
||||||
|
isDestructive={true}
|
||||||
|
isLoading={deleteLocationMut.isPending}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,27 +10,28 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Settings,
|
Settings,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
ShieldAlert
|
ShieldAlert,
|
||||||
|
Users
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { MainSection } from '../types';
|
import { MainSection } from '../types';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentSection: MainSection;
|
|
||||||
setCurrentSection: (section: MainSection) => void;
|
|
||||||
isCoimbatoreView: boolean;
|
isCoimbatoreView: boolean;
|
||||||
setIsCoimbatoreView: (val: boolean) => void;
|
setIsCoimbatoreView: (val: boolean) => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
currentSection,
|
|
||||||
setCurrentSection,
|
|
||||||
isCoimbatoreView,
|
isCoimbatoreView,
|
||||||
setIsCoimbatoreView,
|
setIsCoimbatoreView,
|
||||||
isOpen,
|
isOpen,
|
||||||
isAdmin
|
isAdmin,
|
||||||
|
onClose
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
const location = useLocation();
|
||||||
// Navigation elements
|
// Navigation elements
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
||||||
@@ -40,36 +41,48 @@ export default function Sidebar({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex-col py-xl pt-16 z-40 hidden md:flex transition-all duration-300 ${
|
{/* Mobile Overlay */}
|
||||||
isOpen ? 'w-64' : 'w-20'
|
{isOpen && (
|
||||||
}`}
|
<div
|
||||||
>
|
className="md:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-sm"
|
||||||
{/* Main Navigation Sidebar Links */}
|
onClick={onClose}
|
||||||
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
|
/>
|
||||||
{navItems.map((item) => {
|
)}
|
||||||
const IconComponent = item.icon;
|
|
||||||
const isActive = currentSection === item.id;
|
<aside
|
||||||
return (
|
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
|
||||||
<button
|
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-20'
|
||||||
key={item.id}
|
}`}
|
||||||
onClick={() => setCurrentSection(item.id)}
|
>
|
||||||
title={item.label}
|
{/* Main Navigation Sidebar Links */}
|
||||||
className={`w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
|
||||||
isOpen ? 'gap-md px-md' : 'justify-center px-0'
|
{navItems.map((item) => {
|
||||||
} ${
|
const IconComponent = item.icon;
|
||||||
isActive
|
return (
|
||||||
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-l-4 border-white' : '')
|
<NavLink
|
||||||
: 'text-purple-200 hover:bg-white/10 hover:text-white'
|
key={item.id}
|
||||||
}`}
|
to={`/admin/${item.id}`}
|
||||||
>
|
title={item.label}
|
||||||
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
|
className={({ isActive }) => `w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
||||||
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
|
isOpen ? 'gap-md px-md' : 'justify-center px-0'
|
||||||
</button>
|
} ${
|
||||||
);
|
isActive
|
||||||
})}
|
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-l-4 border-white' : '')
|
||||||
</nav>
|
: 'text-purple-200 hover:bg-white/10 hover:text-white'
|
||||||
</aside>
|
}`}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
|
||||||
|
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox } from 'lucide-react';
|
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox } from 'lucide-react';
|
||||||
import { useFiestaStockStatement, useFiestaCreateStockRequest, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
import { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStockRequests, useFiestaUpdateStockRequest, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||||
import { num as fnum, str as fstr, type Row, FIESTA_PRIMARY_LOCATION_ID } from '../services/fiestaApi';
|
import { num as fnum, str as fstr, type Row, FIESTA_PRIMARY_LOCATION_ID } from '../services/fiestaApi';
|
||||||
import { categoryName } from '../services/fiestaMappers';
|
import { categoryName } from '../services/fiestaMappers';
|
||||||
import { useStoreCatalogue } from '../services/storeCatalogue';
|
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||||
@@ -82,54 +82,29 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
[storeCat.items],
|
[storeCat.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
// The user's picks: productid → request data. Persisted per store.
|
const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: locationid ?? 0, pagesize: 500 });
|
||||||
const storageKey = `nearledaily.catalogue.request.${locationid ?? 'na'}`;
|
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string }>>({});
|
||||||
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected'; requestedAt: string; resolvedAt?: string }>>(() => {
|
|
||||||
try {
|
useEffect(() => {
|
||||||
const raw = localStorage.getItem(storageKey);
|
if (stockRequestsQ.data) {
|
||||||
if (!raw) return {};
|
const map: Record<string, any> = {};
|
||||||
const parsed = JSON.parse(raw);
|
stockRequestsQ.data.forEach((req: any) => {
|
||||||
// Migrate old format (Record<string, number>) to new format
|
if (!map[req.productid]) {
|
||||||
const migrated: Record<string, any> = {};
|
map[req.productid] = {
|
||||||
for (const [k, v] of Object.entries(parsed)) {
|
qty: req.qty,
|
||||||
if (typeof v === 'number') {
|
status: req.status,
|
||||||
migrated[k] = { qty: v, status: 'Pending', requestedAt: new Date().toISOString() };
|
requestedAt: req.created,
|
||||||
} else {
|
resolvedAt: req.updated,
|
||||||
migrated[k] = v;
|
requestid: req.requestid
|
||||||
if (migrated[k].status === 'Approve') migrated[k].status = 'Approved';
|
};
|
||||||
if (migrated[k].status === 'Reject') migrated[k].status = 'Rejected';
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
return migrated;
|
setPicks(map);
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
});
|
}, [stockRequestsQ.data]);
|
||||||
|
|
||||||
// Listen for storage events so approvals from Admin update dynamically
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: StorageEvent) => {
|
|
||||||
if (e.key === storageKey) {
|
|
||||||
if (e.newValue) {
|
|
||||||
const parsed = JSON.parse(e.newValue);
|
|
||||||
for (const key of Object.keys(parsed)) {
|
|
||||||
if (parsed[key].status === 'Approve') parsed[key].status = 'Approved';
|
|
||||||
if (parsed[key].status === 'Reject') parsed[key].status = 'Rejected';
|
|
||||||
}
|
|
||||||
setPicks(parsed);
|
|
||||||
}
|
|
||||||
else setPicks({});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('storage', handler);
|
|
||||||
return () => window.removeEventListener('storage', handler);
|
|
||||||
}, [storageKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(picks));
|
|
||||||
}, [picks, storageKey]);
|
|
||||||
|
|
||||||
const createRequestMutation = useFiestaCreateStockRequest();
|
const createRequestMutation = useFiestaCreateStockRequest();
|
||||||
|
const updateRequestMutation = useFiestaUpdateStockRequest();
|
||||||
|
|
||||||
const togglePick = (id: string) => {
|
const togglePick = (id: string) => {
|
||||||
setNotice(false);
|
setNotice(false);
|
||||||
@@ -271,14 +246,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
// The request is saved to localStorage automatically via the useEffect on `picks`.
|
// The request is saved to localStorage automatically via the useEffect on `picks`.
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
|
<div className="animate-in fade-in duration-300 font-sans pb-28">
|
||||||
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
|
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('catalogue')}
|
onClick={() => setView('catalogue')}
|
||||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
|
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
view === 'catalogue' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
view === 'catalogue' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -286,7 +261,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('inventory')}
|
onClick={() => setView('inventory')}
|
||||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
|
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
view === 'inventory' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
view === 'inventory' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -294,7 +269,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('requests')}
|
onClick={() => setView('requests')}
|
||||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
|
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
view === 'requests' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
view === 'requests' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -302,7 +277,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-6 items-start mt-6">
|
<div className="flex flex-col md:flex-row gap-6 items-start mt-4">
|
||||||
{/* Sticky Sidebar Filter */}
|
{/* Sticky Sidebar Filter */}
|
||||||
{view !== 'requests' && (
|
{view !== 'requests' && (
|
||||||
<div className="w-full md:w-64 shrink-0 bg-white border border-slate-200 rounded-2xl p-5 sticky top-24 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] z-10 hidden md:flex flex-col gap-6">
|
<div className="w-full md:w-64 shrink-0 bg-white border border-slate-200 rounded-2xl p-5 sticky top-24 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] z-10 hidden md:flex flex-col gap-6">
|
||||||
@@ -439,49 +414,53 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
sub="Try a different keyword or clear the filters to see the full catalogue."
|
sub="Try a different keyword or clear the filters to see the full catalogue."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4 pb-8">
|
||||||
{filtered.map((p) => {
|
{filtered.map((p) => {
|
||||||
const stocked = inStore.has(p.id);
|
const stocked = inStore.has(p.id);
|
||||||
const pick = picks[p.id];
|
const pick = picks[p.id];
|
||||||
const picked = pick != null && pick.status !== 'Cancelled' && pick.status !== 'Approved';
|
const picked = pick != null && pick.status !== 'Cancelled' && pick.status !== 'Approved';
|
||||||
return (
|
return (
|
||||||
<div key={p.id} onClick={() => setSelectedProduct(p)} className="bg-white border border-slate-200 rounded-2xl flex flex-col shadow-sm hover:shadow-xl hover:border-purple-300 hover:-translate-y-1.5 transition-all duration-300 relative group cursor-pointer overflow-hidden">
|
<div key={p.id} onClick={() => setSelectedProduct(p)} className="cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden">
|
||||||
{/* Image Bento Block */}
|
{/* Image Bento Block */}
|
||||||
<div className="w-full h-32 bg-slate-50 rounded-t-2xl relative overflow-hidden shrink-0 border-b border-slate-100 p-2">
|
<div className="w-full h-28 bg-zinc-50 relative overflow-hidden shrink-0">
|
||||||
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover rounded-xl group-hover:scale-105 transition-transform duration-500 ease-out" />
|
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700 ease-in-out" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-2xl pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
|
||||||
{stocked && (
|
{stocked && (
|
||||||
<div className="absolute top-3 right-3 bg-white/90 backdrop-blur-md px-1.5 py-1 rounded-md shadow flex items-center gap-1 z-10">
|
<div className="absolute top-2 right-2 bg-emerald-100/90 backdrop-blur-sm border border-emerald-200 px-1.5 py-0.5 rounded shadow-sm flex items-center gap-1 z-10">
|
||||||
<CheckCircle2 size={12} className="text-emerald-500" />
|
<CheckCircle2 size={10} className="text-emerald-700" />
|
||||||
<span className="text-[9px] font-black uppercase text-emerald-700 tracking-wider">In Store</span>
|
<span className="text-[8px] font-extrabold uppercase text-emerald-800 tracking-wider">In Store</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
<div className="absolute top-2 left-2 z-10 flex flex-col items-start gap-1">
|
||||||
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(p.category)}`}>
|
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shadow-sm ${
|
||||||
|
p.category.startsWith('Staples') ? 'bg-amber-100 text-amber-800' :
|
||||||
|
p.category.startsWith('Groceries') ? 'bg-emerald-100 text-emerald-800' :
|
||||||
|
p.category.startsWith('Beverages') ? 'bg-sky-100 text-sky-800' :
|
||||||
|
'bg-rose-100 text-rose-800'
|
||||||
|
}`}>
|
||||||
{p.category.split(' / ')[0]}
|
{p.category.split(' / ')[0]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details Bento Block */}
|
{/* Details Bento Block */}
|
||||||
<div className="p-3.5 flex flex-col flex-1 gap-3">
|
<div className="p-3 flex flex-col flex-1 gap-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div className="flex-1 min-w-0 pb-2">
|
||||||
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{p.name}</h4>
|
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2">{p.name}</h4>
|
||||||
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{p.sku}</p>
|
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">{p.sku}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto bg-slate-50 rounded-xl p-2.5 border border-slate-100 flex justify-between items-center">
|
<div className="grid grid-cols-2 gap-1.5 p-2 bg-slate-50/80 rounded-xl border border-slate-100 mt-auto">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-0.5">Price</span>
|
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-extrabold mb-0.5">Price</span>
|
||||||
<span className="font-extrabold text-slate-800 font-mono text-sm">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
<span className="font-extrabold text-slate-800 font-mono text-xs">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-0.5">Unit</span>
|
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-extrabold mb-0.5">Unit</span>
|
||||||
<span className="font-bold text-slate-600 text-[11px] bg-white px-1.5 py-0.5 rounded shadow-sm border border-slate-100">{p.unit || 'Pc'}</span>
|
<span className="font-bold text-slate-600 text-xs bg-white px-1.5 py-0.5 rounded shadow-sm border border-slate-100">{p.unit || 'Pc'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -545,7 +524,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
<table className="w-full text-left border-collapse" style={{ minWidth: 800 }}>
|
<table className="w-full text-left border-collapse" style={{ minWidth: 800 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-50/80 border-b border-slate-200">
|
<tr className="bg-slate-50/80 border-b border-slate-200">
|
||||||
{['Requested At', 'Product', 'Qty', 'Status', 'Resolved At'].map((h, i) => (
|
{['Requested At', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => (
|
||||||
<th key={i} className="px-5 py-4 text-[10px] font-black uppercase tracking-widest text-slate-500 whitespace-nowrap">{h}</th>
|
<th key={i} className="px-5 py-4 text-[10px] font-black uppercase tracking-widest text-slate-500 whitespace-nowrap">{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -612,6 +591,25 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
{data.resolvedAt ? new Date(data.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
|
{data.resolvedAt ? new Date(data.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-5 py-4 whitespace-nowrap text-right">
|
||||||
|
{isApproved && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateRequestMutation.mutate({
|
||||||
|
tenantid,
|
||||||
|
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
|
||||||
|
productid: Number(pid),
|
||||||
|
requestid: data.requestid,
|
||||||
|
status: 'Received'
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold text-white bg-indigo-600 hover:bg-indigo-700 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={14} /> Mark as Received
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -642,38 +640,42 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4">
|
||||||
{finalFilteredInventory.map((it, i) => (
|
{finalFilteredInventory.map((it, i) => (
|
||||||
<div key={it.id || i} onClick={() => setSelectedProduct(it)} className="bg-white border border-slate-200 rounded-2xl flex flex-col shadow-sm hover:shadow-xl hover:border-purple-300 hover:-translate-y-1.5 transition-all duration-300 relative group cursor-pointer overflow-hidden">
|
<div key={it.id || i} onClick={() => setSelectedProduct(it)} className="cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden">
|
||||||
{/* Image Bento Block */}
|
{/* Image Bento Block */}
|
||||||
<div className="w-full h-32 bg-slate-50 rounded-t-2xl relative overflow-hidden shrink-0 border-b border-slate-100 p-2">
|
<div className="w-full h-28 bg-zinc-50 relative overflow-hidden shrink-0">
|
||||||
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover rounded-xl group-hover:scale-105 transition-transform duration-500 ease-out" />
|
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700 ease-in-out" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-2xl pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
|
||||||
<div className="absolute top-3 right-3 bg-white/90 backdrop-blur-md px-1.5 py-1 rounded-md shadow flex items-center gap-1 z-10" style={{ color: it.color }}>
|
<div className="absolute top-2 right-2 bg-white/90 backdrop-blur-md border border-slate-200 px-1.5 py-0.5 rounded shadow-sm flex items-center gap-1 z-10" style={{ color: it.color }}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: it.color }} />
|
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: it.color }} />
|
||||||
<span className="text-[9px] font-black uppercase tracking-wider">{it.label}</span>
|
<span className="text-[8px] font-extrabold uppercase tracking-wider">{it.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
<div className="absolute top-2 left-2 z-10 flex flex-col items-start gap-1">
|
||||||
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(it.category)}`}>
|
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shadow-sm ${
|
||||||
|
it.category.startsWith('Staples') ? 'bg-amber-100 text-amber-800' :
|
||||||
|
it.category.startsWith('Groceries') ? 'bg-emerald-100 text-emerald-800' :
|
||||||
|
it.category.startsWith('Beverages') ? 'bg-sky-100 text-sky-800' :
|
||||||
|
'bg-rose-100 text-rose-800'
|
||||||
|
}`}>
|
||||||
{it.category.split(' / ')[0]}
|
{it.category.split(' / ')[0]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details Bento Block */}
|
{/* Details Bento Block */}
|
||||||
<div className="p-3.5 flex flex-col flex-1 gap-3">
|
<div className="p-3 flex flex-col flex-1 gap-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div className="flex-1 min-w-0 pb-2">
|
||||||
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{it.name}</h4>
|
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2">{it.name}</h4>
|
||||||
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{it.sku}</p>
|
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">{it.sku}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto bg-slate-50 rounded-xl p-3 border border-slate-100 text-center">
|
<div className="mt-auto bg-slate-50/80 rounded-xl p-2.5 border border-slate-100 text-center">
|
||||||
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-1">Live Stock</span>
|
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-extrabold mb-0.5">Live Stock</span>
|
||||||
<span className="font-black font-mono text-xl" style={{ color: it.color }}>{(it.closing ?? 0).toLocaleString('en-IN')}</span>
|
<span className="font-extrabold font-mono text-sm" style={{ color: it.color }}>{(it.closing ?? 0).toLocaleString('en-IN')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -964,12 +964,10 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
|||||||
{customersList.length === 0 ? (
|
{customersList.length === 0 ? (
|
||||||
<div className="bg-white/60 backdrop-blur-md border border-[#e2e8f0] rounded-3xl py-24 flex flex-col items-center gap-4 text-center px-6 shadow-sm">
|
<div className="bg-white/60 backdrop-blur-md border border-[#e2e8f0] rounded-3xl py-24 flex flex-col items-center gap-4 text-center px-6 shadow-sm">
|
||||||
<span className="flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#f3e8ff] to-[#e9d5ff] text-[#6d28d9] shadow-sm"><Users size={28} /></span>
|
<span className="flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#f3e8ff] to-[#e9d5ff] text-[#6d28d9] shadow-sm"><Users size={28} /></span>
|
||||||
<div>
|
<p className="text-[18px] font-bold text-[#0f172a]">No customers yet</p>
|
||||||
<p className="text-[18px] font-bold text-[#0f172a]">No customers yet</p>
|
<p className="text-[14px] text-zinc-500 max-w-[350px] leading-relaxed">
|
||||||
<p className="text-[14px] text-zinc-500 mt-1 max-w-sm leading-relaxed">
|
{customerSearch ? 'Nothing matches your search criteria.' : 'Customers will appear here once they place their first order.'}
|
||||||
{customerSearch ? 'Nothing matches your search criteria.' : 'Customers will appear here once they place their first order.'}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{customerSearch && (
|
{customerSearch && (
|
||||||
<button onClick={() => setCustomerSearch('')} className="mt-2 text-[13px] font-bold text-[#662582] hover:text-[#4c1d95] bg-purple-50 px-4 py-2 rounded-lg transition-colors cursor-pointer">Clear Search</button>
|
<button onClick={() => setCustomerSearch('')} className="mt-2 text-[13px] font-bold text-[#662582] hover:text-[#4c1d95] bg-purple-50 px-4 py-2 rounded-lg transition-colors cursor-pointer">Clear Search</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Truck,
|
Truck,
|
||||||
Route,
|
Route as RouteIcon,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Layers,
|
Layers,
|
||||||
Users,
|
Users,
|
||||||
@@ -49,9 +50,8 @@ interface UserStorePageProps {
|
|||||||
const NAV_ITEMS: UserNavItem[] = [
|
const NAV_ITEMS: UserNavItem[] = [
|
||||||
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
||||||
{ id: 'inventory', label: 'Products', icon: Layers },
|
{ id: 'inventory', label: 'Products', icon: Layers },
|
||||||
{ id: 'pos', label: 'POS Terminal', icon: ShoppingBag },
|
|
||||||
{ id: 'customers', label: 'Customers', icon: Users },
|
{ id: 'customers', label: 'Customers', icon: Users },
|
||||||
{ id: 'dispatch', label: 'Dispatch', icon: Route },
|
{ id: 'dispatch', label: 'Dispatch', icon: RouteIcon },
|
||||||
{ id: 'reports', label: 'Reports', icon: ClipboardList },
|
{ id: 'reports', label: 'Reports', icon: ClipboardList },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -65,8 +65,13 @@ type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
|
|||||||
*/
|
*/
|
||||||
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [activeSection, setActiveSection] = useState<string>('console');
|
|
||||||
const [showQrModal, setShowQrModal] = useState(false);
|
const [showQrModal, setShowQrModal] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Extract the current section from the URL for Header context
|
||||||
|
const pathParts = location.pathname.split('/');
|
||||||
|
const activeSection = pathParts[pathParts.length - 1] || 'console';
|
||||||
|
|
||||||
// Scope every query to the signed-in merchant's tenant; the shared constant is
|
// Scope every query to the signed-in merchant's tenant; the shared constant is
|
||||||
// only a fallback for legacy sessions whose record predates tenantid capture.
|
// only a fallback for legacy sessions whose record predates tenantid capture.
|
||||||
@@ -191,26 +196,6 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
const renderSection = () => {
|
const renderSection = () => {
|
||||||
if (activeSection === 'account') return renderAccount();
|
if (activeSection === 'account') return renderAccount();
|
||||||
|
|
||||||
// Logistics console — scoped to this user's store. These views own their
|
|
||||||
// loading/error states, so they don't need the store-console load gating below.
|
|
||||||
if (activeSection === 'pos') return <PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
|
||||||
if (activeSection === 'dispatch') return <DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
|
||||||
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />;
|
|
||||||
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
|
||||||
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
|
||||||
// gating below — only "My Store Inventory" uses the resolved location id).
|
|
||||||
if (activeSection === 'inventory') return <StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />;
|
|
||||||
|
|
||||||
// The store console needs a resolved store, so gate it on the load state.
|
|
||||||
if (locationsQ.isLoading || locSummaryQ.isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-24">
|
|
||||||
<div className="w-7 h-7 border-2 border-[#662582] border-t-transparent rounded-full animate-spin" />
|
|
||||||
<span className="text-xs font-semibold text-slate-500">Loading your store…</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Distinguish a real load failure from "no store assigned" — otherwise an
|
// Distinguish a real load failure from "no store assigned" — otherwise an
|
||||||
// API outage would wrongly tell the user to contact their admin.
|
// API outage would wrongly tell the user to contact their admin.
|
||||||
if (locationsQ.isError || locSummaryQ.isError) {
|
if (locationsQ.isError || locSummaryQ.isError) {
|
||||||
@@ -255,6 +240,26 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logistics console — scoped to this user's store. These views own their
|
||||||
|
// loading/error states, so they don't need the store-console load gating below.
|
||||||
|
if (activeSection === 'pos') return <PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
||||||
|
if (activeSection === 'dispatch') return <DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
||||||
|
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />;
|
||||||
|
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
||||||
|
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
||||||
|
// gating below — only "My Store Inventory" uses the resolved location id).
|
||||||
|
if (activeSection === 'inventory') return <StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />;
|
||||||
|
|
||||||
|
// The store console needs a resolved store, so gate it on the load state.
|
||||||
|
if (locationsQ.isLoading || locSummaryQ.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-24">
|
||||||
|
<div className="w-7 h-7 border-2 border-[#662582] border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-xs font-semibold text-slate-500">Loading your store…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// canManage=false hides write actions in the console. NOTE: this is a UI-only
|
// canManage=false hides write actions in the console. NOTE: this is a UI-only
|
||||||
// restriction — the backend must also enforce role-based authorization on the
|
// restriction — the backend must also enforce role-based authorization on the
|
||||||
// write endpoints, since a hidden button doesn't stop a direct API call.
|
// write endpoints, since a hidden button doesn't stop a direct API call.
|
||||||
@@ -267,13 +272,13 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased">
|
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased overflow-x-hidden">
|
||||||
<Header
|
<Header
|
||||||
isSidebarOpen={sidebarOpen}
|
isSidebarOpen={sidebarOpen}
|
||||||
onToggleSidebar={() => setSidebarOpen((s) => !s)}
|
onToggleSidebar={() => setSidebarOpen((s) => !s)}
|
||||||
onHelpClick={handleHelp}
|
onHelpClick={handleHelp}
|
||||||
onLogoutClick={onLogout}
|
onLogoutClick={onLogout}
|
||||||
onAccountClick={() => setActiveSection('account')}
|
onAccountClick={() => navigate('/store/account')}
|
||||||
onQrClick={() => setShowQrModal(true)}
|
onQrClick={() => setShowQrModal(true)}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
storeContext={{
|
storeContext={{
|
||||||
@@ -295,35 +300,63 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
: activeSection === 'customers'
|
: activeSection === 'customers'
|
||||||
? Users
|
? Users
|
||||||
: activeSection === 'dispatch'
|
: activeSection === 'dispatch'
|
||||||
? Route
|
? RouteIcon
|
||||||
: activeSection === 'reports'
|
: activeSection === 'reports'
|
||||||
? ClipboardList
|
? ClipboardList
|
||||||
: undefined
|
: undefined
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex pt-16">
|
<div className="flex pt-16 w-full max-w-[100vw]">
|
||||||
<UserStoreSidebar
|
<UserStoreSidebar
|
||||||
items={NAV_ITEMS}
|
items={NAV_ITEMS}
|
||||||
activeId={activeSection}
|
|
||||||
onSelect={setActiveSection}
|
|
||||||
isOpen={sidebarOpen}
|
isOpen={sidebarOpen}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className={`flex-1 min-w-0 transition-all duration-300 ${
|
className={`flex-1 min-w-0 transition-all duration-300 ${
|
||||||
// Dispatch is a full-bleed cockpit — fill the area exactly (no page
|
|
||||||
// padding) so it sits flush under the header. Other pages stay padded.
|
|
||||||
activeSection === 'dispatch' || activeSection === 'pos' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'
|
activeSection === 'dispatch' || activeSection === 'pos' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'
|
||||||
} ${sidebarOpen ? 'md:pl-64' : 'md:pl-20'}`}
|
} ${sidebarOpen ? 'md:pl-64' : 'md:pl-20'}`}
|
||||||
>
|
>
|
||||||
{activeSection === 'dispatch' || activeSection === 'pos' ? (
|
<Routes>
|
||||||
<div className="w-full h-full">{renderSection()}</div>
|
<Route index element={<Navigate to="console" replace />} />
|
||||||
) : (
|
<Route path="account" element={
|
||||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||||
{renderSection()}
|
{renderAccount()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
} />
|
||||||
|
<Route path="pos" element={
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
<Route path="dispatch" element={
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
<Route path="reports" element={
|
||||||
|
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||||
|
<DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
<Route path="inventory" element={
|
||||||
|
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||||
|
<StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
<Route path="console" element={
|
||||||
|
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||||
|
<StoreDetailView store={buildStore()} canManage={false} only="overview" tenantId={tenantId} />
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
<Route path="customers" element={
|
||||||
|
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||||
|
<StoreDetailView store={buildStore()} canManage={false} only="customers" tenantId={tenantId} />
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { type LucideIcon } from 'lucide-react';
|
import { type LucideIcon } from 'lucide-react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
/** One entry in the user store sidebar. Add to `UserStorePage`'s `NAV_ITEMS`. */
|
/** One entry in the user store sidebar. Add to `UserStorePage`'s `NAV_ITEMS`. */
|
||||||
export interface UserNavItem {
|
export interface UserNavItem {
|
||||||
@@ -15,10 +16,9 @@ export interface UserNavItem {
|
|||||||
|
|
||||||
interface UserStoreSidebarProps {
|
interface UserStoreSidebarProps {
|
||||||
items: UserNavItem[];
|
items: UserNavItem[];
|
||||||
activeId: string;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
/** Collapsed → icon-only rail; expanded → labels visible. */
|
/** Collapsed → icon-only rail; expanded → labels visible. */
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,24 +26,32 @@ interface UserStoreSidebarProps {
|
|||||||
* (fixed, sits under the fixed Header, collapses to an icon rail) but driven by
|
* (fixed, sits under the fixed Header, collapses to an icon rail) but driven by
|
||||||
* a generic items list so new user sections can be added without touching this.
|
* a generic items list so new user sections can be added without touching this.
|
||||||
*/
|
*/
|
||||||
export default function UserStoreSidebar({ items, activeId, onSelect, isOpen }: UserStoreSidebarProps) {
|
export default function UserStoreSidebar({ items, isOpen, onClose }: UserStoreSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex-col py-xl pt-16 z-40 hidden md:flex transition-all duration-300 ${
|
{/* Mobile Overlay */}
|
||||||
isOpen ? 'w-64' : 'w-20'
|
{isOpen && (
|
||||||
}`}
|
<div
|
||||||
>
|
className="md:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
|
||||||
|
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{/* Main Navigation Sidebar Links */}
|
{/* Main Navigation Sidebar Links */}
|
||||||
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
|
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const IconComponent = item.icon;
|
const IconComponent = item.icon;
|
||||||
const isActive = activeId === item.id;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<NavLink
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => onSelect(item.id)}
|
to={`/store/${item.id}`}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
className={`w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
className={({ isActive }) => `w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
||||||
isOpen ? 'gap-md px-md' : 'justify-center px-0'
|
isOpen ? 'gap-md px-md' : 'justify-center px-0'
|
||||||
} ${
|
} ${
|
||||||
isActive
|
isActive
|
||||||
@@ -51,12 +59,17 @@ export default function UserStoreSidebar({ items, activeId, onSelect, isOpen }:
|
|||||||
: 'text-purple-200 hover:bg-white/10 hover:text-white'
|
: 'text-purple-200 hover:bg-white/10 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
|
{({ isActive }) => (
|
||||||
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
|
<>
|
||||||
</button>
|
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
|
||||||
|
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,15 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<CompareProvider>
|
<CompareProvider>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</CompareProvider>
|
</CompareProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -208,11 +208,18 @@ export async function checkEmailRequest(email: string): Promise<boolean> {
|
|||||||
/** Find a user in the tenant user list by email or authname (case-insensitive). */
|
/** Find a user in the tenant user list by email or authname (case-insensitive). */
|
||||||
export function matchTenantUser(users: Row[], email: string): Row | null {
|
export function matchTenantUser(users: Row[], email: string): Row | null {
|
||||||
const target = email.toLowerCase();
|
const target = email.toLowerCase();
|
||||||
|
const prefix = target.split('@')[0] + '@';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
users.find(
|
users.find(
|
||||||
(u) =>
|
(u) =>
|
||||||
str(u[RESPONSE_FIELDS.email]).toLowerCase() === target ||
|
str(u[RESPONSE_FIELDS.email]).toLowerCase() === target ||
|
||||||
str(u.authname).toLowerCase() === target,
|
str(u.authname).toLowerCase() === target
|
||||||
|
) ??
|
||||||
|
users.find(
|
||||||
|
(u) =>
|
||||||
|
str(u[RESPONSE_FIELDS.email]).toLowerCase().startsWith(prefix) ||
|
||||||
|
str(u.authname).toLowerCase().startsWith(prefix)
|
||||||
) ?? null
|
) ?? null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1029,6 +1029,10 @@ export interface CreateTenantLocationInput {
|
|||||||
latitude?: string;
|
latitude?: string;
|
||||||
longitude?: string;
|
longitude?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
moduleid?: number;
|
||||||
|
applocationid?: number;
|
||||||
|
partnerid?: number;
|
||||||
|
roleid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /tenants/createtenantlocation — Create a new store location under a tenant. */
|
/** POST /tenants/createtenantlocation — Create a new store location under a tenant. */
|
||||||
@@ -1043,6 +1047,8 @@ export interface UpdateTenantLocationInput {
|
|||||||
contactno?: string;
|
contactno?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
suburb?: string;
|
||||||
|
city?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PUT /tenants/updatetenantlocation — Update store location details/status. */
|
/** PUT /tenants/updatetenantlocation — Update store location details/status. */
|
||||||
@@ -1136,3 +1142,71 @@ export async function reassignDeliveries(opts: {
|
|||||||
return fiestaSend<Row>('riders/reassigndeliveries', 'POST', opts);
|
return fiestaSend<Row>('riders/reassigndeliveries', 'POST', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST /v1/web/tenants/createlocation — Create a new tenant location (outlet). */
|
||||||
|
export async function createLocation(opts: {
|
||||||
|
tenantid: number;
|
||||||
|
locationname: string;
|
||||||
|
suburb: string;
|
||||||
|
contactno?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<Row> {
|
||||||
|
// Use fiestaSend, note that we include /v1/web explicitly here as the FIESTA_BASE is likely just the root.
|
||||||
|
// Wait, let's look at FIESTA_BASE in the file.
|
||||||
|
// Actually, I'll just use the full endpoint path.
|
||||||
|
return fiestaSend<Row>('v1/web/tenants/createlocation', 'POST', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /v1/web/tenants/updatelocation — Update an existing tenant location (outlet). */
|
||||||
|
export async function updateLocation(opts: {
|
||||||
|
tenantid: number;
|
||||||
|
locationid: number;
|
||||||
|
locationname?: string;
|
||||||
|
suburb?: string;
|
||||||
|
contactno?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<Row> {
|
||||||
|
return fiestaSend<Row>('v1/web/tenants/updatelocation', 'PUT', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /v1/web/tenants/deletelocation — Delete an existing tenant location (outlet). */
|
||||||
|
export async function deleteLocation(opts: {
|
||||||
|
tenantid: number;
|
||||||
|
locationid: number;
|
||||||
|
}) {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
tenantid: String(opts.tenantid),
|
||||||
|
locationid: String(opts.locationid),
|
||||||
|
}).toString();
|
||||||
|
return await fiestaSend(`tenants/deletelocation?${qs}`, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesSummaryChartData {
|
||||||
|
date: string;
|
||||||
|
revenue: number;
|
||||||
|
orders: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesSummaryTopLocation {
|
||||||
|
locationname: string;
|
||||||
|
revenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesSummaryResponse {
|
||||||
|
totalRevenue: number;
|
||||||
|
totalOrders: number;
|
||||||
|
averageOrderValue: number;
|
||||||
|
chartData: SalesSummaryChartData[];
|
||||||
|
topLocations: SalesSummaryTopLocation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /v1/web/reports/sales-summary — Fetches aggregated sales and revenue data. */
|
||||||
|
export async function getSalesSummary(opts: {
|
||||||
|
tenantid: number;
|
||||||
|
locationid?: number;
|
||||||
|
fromdate: string;
|
||||||
|
todate: string;
|
||||||
|
}): Promise<SalesSummaryResponse> {
|
||||||
|
// Use fiestaGet wrapper which parses standard responses
|
||||||
|
const res = await fiestaGet<{ details: SalesSummaryResponse }>('v1/web/reports/sales-summary', opts);
|
||||||
|
return res.details;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
FIESTA_APPLOCATION_ID,
|
FIESTA_APPLOCATION_ID,
|
||||||
FIESTA_PRIMARY_LOCATION_ID,
|
FIESTA_PRIMARY_LOCATION_ID,
|
||||||
getOrderSummary,
|
getOrderSummary,
|
||||||
|
getSalesSummary,
|
||||||
getRevenueSummary,
|
getRevenueSummary,
|
||||||
getTimeSeries,
|
getTimeSeries,
|
||||||
getLocationSummary,
|
getLocationSummary,
|
||||||
@@ -61,6 +62,7 @@ import {
|
|||||||
CreateTenantLocationInput,
|
CreateTenantLocationInput,
|
||||||
updateTenantLocation,
|
updateTenantLocation,
|
||||||
UpdateTenantLocationInput,
|
UpdateTenantLocationInput,
|
||||||
|
deleteLocation,
|
||||||
createProductLocation,
|
createProductLocation,
|
||||||
createStockRequest,
|
createStockRequest,
|
||||||
getStockRequests,
|
getStockRequests,
|
||||||
@@ -73,6 +75,8 @@ import {
|
|||||||
export const fiestaKeys = {
|
export const fiestaKeys = {
|
||||||
orderSummary: (tenantid: number, fromdate: string, todate: string, locationid?: number) =>
|
orderSummary: (tenantid: number, fromdate: string, todate: string, locationid?: number) =>
|
||||||
['fiesta', 'orderSummary', tenantid, fromdate, todate, locationid ?? 0] as const,
|
['fiesta', 'orderSummary', tenantid, fromdate, todate, locationid ?? 0] as const,
|
||||||
|
salesSummary: (tenantid: number, locationid: number | undefined, fromdate: string, todate: string) =>
|
||||||
|
['fiesta', 'salesSummary', tenantid, locationid, fromdate, todate] as const,
|
||||||
revenueSummary: (params: Record<string, unknown>) => ['fiesta', 'revenueSummary', params] as const,
|
revenueSummary: (params: Record<string, unknown>) => ['fiesta', 'revenueSummary', params] as const,
|
||||||
timeSeries: (params: Record<string, unknown>) => ['fiesta', 'timeSeries', params] as const,
|
timeSeries: (params: Record<string, unknown>) => ['fiesta', 'timeSeries', params] as const,
|
||||||
locationSummary: (tenantid: number) => ['fiesta', 'locationSummary', tenantid] as const,
|
locationSummary: (tenantid: number) => ['fiesta', 'locationSummary', tenantid] as const,
|
||||||
@@ -143,6 +147,19 @@ export function useFiestaTimeSeries(opts: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useFiestaSalesSummary(opts: {
|
||||||
|
tenantid: number;
|
||||||
|
locationid?: number;
|
||||||
|
fromdate: string;
|
||||||
|
todate: string;
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fiestaKeys.salesSummary(opts.tenantid, opts.locationid, opts.fromdate, opts.todate),
|
||||||
|
queryFn: () => getSalesSummary(opts),
|
||||||
|
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useFiestaLocationSummary(tenantid: number = FIESTA_TENANT_ID) {
|
export function useFiestaLocationSummary(tenantid: number = FIESTA_TENANT_ID) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: fiestaKeys.locationSummary(tenantid),
|
queryKey: fiestaKeys.locationSummary(tenantid),
|
||||||
@@ -848,4 +865,15 @@ export function useLogin() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useFiestaDeleteLocation() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: { tenantid: number; locationid: number }) => deleteLocation(input),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
qc.invalidateQueries({ queryKey: fiestaKeys.tenantLocations(variables.tenantid) });
|
||||||
|
qc.invalidateQueries({ queryKey: fiestaKeys.locationSummary(variables.tenantid) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, FIESTA_PRIMARY_LOCATION_ID };
|
export { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, FIESTA_PRIMARY_LOCATION_ID };
|
||||||
|
|||||||
Reference in New Issue
Block a user