name: CI/CD - ERP Core Monorepo

on:
  pull_request:
    branches:
      - production
    types:
      - closed

jobs:
  backend-build-and-deploy:
    name: Build & Deploy ERP Core Backend
    runs-on: linux
    if: github.event.pull_request.merged == true
    steps:
      - name: Checkout code
        run: |
          git clone \
            --depth 1 \
            --branch production \
            https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
            .

      - name: Restore backend
        run: dotnet restore Backend/ERPCore.sln

      - name: Publish backend
        run: |
          rm -rf /tmp/erpcore-deploy
          mkdir -p /tmp/erpcore-deploy
          dotnet publish Backend/ERPCore/ERPCore.csproj \
            -c Release \
            -r linux-x64 \
            --self-contained true \
            -o /tmp/erpcore-deploy

      - name: Stop backend service
        run: systemctl stop erpcore.service

      - name: Copy backend to deploy folder
        run: |
          mkdir -p /home/erp/core/publish
          rm -rf /home/erp/core/publish/*
          cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/

      - name: Fix backend ownership and permissions
        run: |
          chown -R erpuser:erpuser /home/erp/core/publish
          find /home/erp/core/publish -type d -exec chmod 755 {} +
          find /home/erp/core/publish -type f -exec chmod 644 {} +
          chmod +x /home/erp/core/publish/ERPCore

      - name: Restart backend service
        run: systemctl restart erpcore.service

      - name: Verify backend
        run: |
          systemctl is-active erpcore.service && echo "Backend service running" || echo "Backend service failed"

  frontend-build-and-deploy:
    name: Build & Deploy ERP Core Frontend
    runs-on: linux
    needs: backend-build-and-deploy
    if: github.event.pull_request.merged == true
    steps:
      - name: Checkout code
        run: |
          git clone \
            --depth 1 \
            --branch Production \
            https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
            .

      - name: Build frontend Docker image
        run: docker build -t erpcore-frontend:latest Frontend/erp-system

      - name: Stop & remove existing frontend container
        run: |
          docker stop erpcore-frontend 2>/dev/null || true
          docker rm erpcore-frontend 2>/dev/null || true

      - name: Start frontend container
        run: |
          docker run -d \
            --name erpcore-frontend \
            -p 6702:6702 \
            --add-host=host.docker.internal:host-gateway \
            -e BACKEND_ORIGIN=http://host.docker.internal:6701 \
            --restart unless-stopped \
            --memory="512m" \
            --cpus="1.0" \
            --cap-drop ALL \
            --security-opt no-new-privileges \
            erpcore-frontend:latest

      - name: Remove old frontend images
        run: docker image prune -f

      - name: Verify frontend
        run: |
          docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
            | grep erpcore-frontend && echo "Frontend container running" || echo "Frontend container failed"
This commit is contained in:
Kalana B. Thilakarathna
2026-09-17 13:59:05 +05:30
parent 93f2f1bd3b
commit 03d01d4c4e
61 changed files with 2195 additions and 1245 deletions
@@ -0,0 +1 @@
False
@@ -0,0 +1 @@
bf7d0202-9a86-43b3-8323-402073a8c61a
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTyW7bMBD9FYFnUaFIaqFuSeygRpDaiI226I3LKCaikAZFZUHgfy8oO0jTWy8EMeCbecvwHTn5BKhDl4fD8hlcHFGOop1LlNAaE4HLZkeqjrcdF0VZcUoZ+41yZG/hDXWo4VqXWgBmUAPmhlRYsV5jY0CUpDSEVjz1lA8j6t6RtIWBZ6uh8OMPCKP1DnXop3XGv6TZ0hZ68JMpgh9g5cYonU5kFsvt7W69wTdN/X15dXV6OcKYGhTWoA61VUW10AJzISrMdcOx7BuKe6r6UpVNRQScYNZFCE4OxWgePzkYHx1E3dGCsoJgKtAxR0ZGmWgrOcLu7ZCYzDYtUj2fy/O1e0fPEFBH8w9DT+0u9GAvnkY12cFcnM4ox8ek9BD8AUK0MPsySx0GGa132XnS9WDBRZSj9TbbDDL2Pjx9MWt70r/6D/m7NH35CnqKYK795GLCohzdSb23DrLVAnWoLrnhghjMSyUxb3qBJTCJVVUL3ipmakihXnsXrZv8NGYrF+EhzPRRh27kMKZp6212GfTeRtBxCknTrzoBN8GbScfs0/2SFKRghCXQFA9TzO7B2AA6QlK3C9O53z8QWlNCEpUpBHAx28i4z77JcZ8emMZUmgKYkqmGqB6q1rRVqxtT123da15pwspKc65Zr4yQSgmmKae94tDUKEeQws7m/Wr6viW1ajEXjcJcMo6VIBzzsq4kV9JUMiE+fczWQ8KpvjGEEoqFbGvMmWK4ZZRhTihpmGx1XaZV+rDknD1PmdxCcDD8JfnO6uBH38fsvATZFxPuJ5f+bjYvxIt1+HV2e+H1I4QspSWtS2t6Duh4PB7/AAAA//8DAAY6R+UCBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTyU7dMBT9lcjrOHiKHWfHqCJEQYDaqjvHvuFZBPvJcRiE3r9XDg9RuuvOuvI5PsP1GwrmEVCPDrfb0ycIeUY1yn4dMcIkJhpTdUfaXnS90A2VHVdK/UY18hfwinqkhLXUasAcJGDhSIsHPlrsHGhKqCOsFYXT3M+of0PGNw6evIUmzj8gzT4G1KOfPrj4XN42vrFTXFyT4gTnYc4m2CLm5PT24u7qGp8p+f306Oj95gxzIWi8Qz3q2pZZbTUWWrdYWCWwGRXDIxtGOlDVEg3vMB8ypGCmZnYPnxpczAGy7VnDeEMw02hXI2eyKbIHM8Pd67YoWWM6KfN6Ha/H/g09QUI9qz8Cfac7sJM/sPHx0QR3MPrg5w2q0TbFLaTsYY1kdTlNJvsYqv0jx5OHkFGNrm6r68nkMabHLzndvls//w/nl8ZufIDq/AT1SFLhhCYOCzoYLNSosQFu8NBKLbqBOwmltuMYsg9LXObqPGS4T6tK1KMzM82F9Oq2Okx24zPYvKQi/ZcsQHjx+Ti6MiCoRtcpusXm6jNuShrScMILx5K3S65uwPkENkPxdJeWPf0/ECYZKYzHS0oQcnVt8qb6ZuZNueCUay0DcJQPigwjtJ3r2s4qJ2UnRytaSzhtrRCWj4PTZhg0t0ywcRCgZNFd2q3WhQJCmNCK4lGqFgupKDbcScwJjAoYpa4riM9Yq6up4IZROcIIw9p0Egs+cNxxxrEgjChuOitp2Z2PSPaNiw7V6AJSgOkvy5fepjjHMVf76qsvIdwsoXzWal2DZx/wyxr+SbQPkKpSnvGh7OW+r91ut/sDAAD//wMAfxRohPMDAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTS0/jMBD+K5HPdXBsJ3FyA1q0FWKpoNpd7c0eT6hFcCrH4SHU/75yWsSyt73E0cgz8738Trx+QtKS8/1+9Yw+jmRBoptLnPGKsoYW9ZaVrVStbHKlmkpU8jdZEHeNb6QltQQooEEqsEIqLSupER1Qa7EpWGEZL2WaqR9G0r4T7XKLzw4wH8YfGEY3eNKSn87b4SXt1i6HfphsHoYe136M2kMCs1zdX29vN/Sqrr6vLi6ON0cc04DcWdKSqkOjrC5p3TRAJTSGqrqpqdaisx2W6Ty2OR8xeN3no338xGCH6DFCy3MuckZ5Qw4LYnXUCbbRI27f9gnJLNMy1Rdzef5t38kzBtLyxYegx3Fn0Luzp9FMrrdnx2/U42Niug/DHkN0OOsyU+17Hd3gs9Omy96hj2RBbu+zTa9jN4SnL2LdH/mv/4P+Nm1fvSJMEe3lMPlIWqLIgtxo2DmP2XqZhhXSyoZZKgujqay7hmoUmpqyaqQywlaYTL0cfHR+GqYxW/uID2GGT1pypfsRj8DPA+xcRIhTSJx+ValxEwY7Qcw+1S9YznLBRGqa4n6K2R1aFxAiJnbbMJ3m/dPCK85YgjKFgD5mGx132Tc97tIFW9sSOKIthKmZ6bBUVpUKaltVqupAlsBEUYKUIDpjG21MI4BL3hmJdUUWBJPZ2ZwvbQ2zrKip6rim0uqCKgBGrapBAGCBUHzRMbvtU5/pass447TRqqJSGEGV4IJKxlkttIKqSFH6kOTkvUyeXGPw2P9F+cZBGMahi9kpBNkXEe4mn95uNgfixXn6Oqu9HOARQ5bc0s6nmJ4MOhwOhz8AAAD//wMACwdnnQIEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTS2/cIBSF/4rF2jgYsA3e5alGUZooGbVVdzyuExQHRoDzUDT/vcIzVZvuusHoinM5fOf6A3n1DGhEx9vt+Qv4nFCNsltLlNAeE4nbYUO6kYuRy0aInhJJfqIauSt4RyMauDGtkYAZ9IC5JR3WbDLYWpAtaS2hHS891UNC4wdSrrHw4gw0IX2DmFzwaETfnbfhtdytXGPmsNgmhhkufcrKm2Lm7Pz+anNziy+G/uv5ycn+ZIJUGjTOohH1E2hhVYcHKQ3mRmosBjlgpdhkJ+jKdy9zPkP0am6SffrjwYbsIZuRNpQ1BFOJdjWyKqtiW6sEm/dtcbJiOiv1ei2v2/EDvUBEI61/A923OzKzO3pOenGzPdqvWaWntK6TMjnEd1SjbQxbiNnByug4JXjW8/tGpaeL/ZmyTedvYJYM9jQsPqMRCVSjFdE8q+yCrw4OT2cHPqMa3dxXt7PKU4jPnyDf77ld/ge2a2UenYfq8qxoWm65JBbzVivMh0liBUxh3fWSC81sDyXz0+Cz80tYUnXpMzzE1SUa0YWaE+z9HUfz6DKYvMRi/UdfhLcx2MXk6k84LWlIwwgroiVvl1zdgXURTIbyiE1cDv3+kdCeElKsLDGCz9Wtyo/VF5UeywE72M5QANsyPRA9QSes6IQZbN+LfjK8M4S1neHcsElbqbSWzFBOJ81h6FGNoMxCtY6fsUQTbQQeqLGYc9liwcSAmSJaU2HEpOUnjtXNXHR6GiyhhGKpRI850wwLRhnmhJKBKWH6tkzabySHiHnJ/gqih/mvJ187E0MKU64OWVefINwtvvza1Zr7q/P4baV9FswTxKqkpZwvU3wIaLfb7X4BAAD//wMA4dUdUiEEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyhTJMUP3ZLYQV0j9SJeNEVvQ3JkC9FSC4raNDD2vxfUbuCmt5xIDPgOZx6+nFcS4YCkJ++Ox7sTxryQmuRxC3HGFWWWtnrPul6aXtpGWGN5K/8mNRkf8DvpiZbet94iFaiQysA66sTgaQhoW9YGxjtZcsLzQvpXAmMT8DR6bOblT0zLOEfSky9jDPO3cjeMjZ/mNTRpnvA+LhmiL8Xc3j097B939KNWf9y9f385ueBSEjRjID1RAzoToKPaWk+lt44abTUFEEMYsCvrRTbGjCnC1Czh61sNYc4Rs+95w0XDKLfkXJMAGUrZDhbcfz+WSjZMtyVeb+Ft27+SEybS8/oH0Eu6Gz+NN3k+TnjC6QhpwXTj58MBYiA1Oab5iCmPuKHZup0myOMcq+tlH6YRYyY1eXyqdhPkYU6Hn3g9XRDc/wKBEyZHetKZDkSrEWQwaMOgOuS2E6ZlKCUIzQODYZBK+KCYVQMCD156r1vOgxWK1OR5mh1Mvy8bP8uZM4FprjhKtFwqZT1DYXDoNLIASjllvA3CaC5NMAIsQGsdam+VLyb5BP5ljFjd35ZuWhmkZYHK1gGVerAUUAB1nbLSOBEUFs2HOeYxrvO6VPcx43Pa+JGefIRpwQu5d8m/jBl9XlOB+pcqwl2aw+pz9eaAljWsEUwU0ZqPa64+YxgT+owF7z6t13z/k3DFGSulrClhzNUO8kv1Gywv5UDQofMcMbTCaeYG7EwwnfE6KGXU4GXnmWg7L6UXgwsWnLPCc8kHJ1EXylgMV20ed8xLaNFT7V1HJVhDQYKhxjDDW9VxZouv3jhWj9OmG3RgnHFqwSgqhRPUCC6oZJxpAcarttj5B5Kr+aQhNXnAFHH6T8ufRp/mZR5ydXVh9ROEz2ss86PaHPltjPSfjfbt7L9iqsprwRjLV7k+0LkmB4RlTXjYxk//Snbln1T7bQpx1TBjTE2eMqS8Hq/hrmsMk+fz+fwvAAAA//8DALVuXjbCBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRU207kOBD9lcivGwffEtt5A7rRIsRF063d1b75UgEvaaflODAI9b+vkoYBZrQP+xJHJVfVqXOO6xVFswPUotP9fv0EMY+oRDksIUZYg4nGVG5J3QrVCl0pzZlk9d+oROEKXlCLpHCOOg2YQwNYeFJjyzuHvQdNCfWE1WKuae5H1L4iEyoPT8FBNYx/QBrDEFGL/gzRD89zbxMq1w+Tr9LQw2Ucs4luBrNab662t3f4QjY367Oz480RxrlAFTxqUdOBVd7UWGrtsHDaYiW1xMbwzndQz+cxLcQMKZq+Gv3jBwY/5AjZtaxivCKYaXQokTfZzLCtGWH7sp+RLDSt5ni5hJff9hU9QUItK98JPZY7cX042Y12Cr0/Wb6oRPs07CHlAAsjy5B9b3IYYvHW47wPEDMq0dmcspmcg3FELdqmCd6j63gfIlwks4PnIT3eHNtWN+stKtFdGv4Bl+9MfkAt4p1TvrOOGqFtZ8HrhtQGtOmEFarznlPXOUY70khpmei48taappG1t5YxVKLbTXHXm9wNafdFr81Rgsv/ocDm9Hwdje1hzrkw/fhjpK1J95BRiyylnVNSa+FZrT0H4xQI2lhPjFIcBHgute6o4pxxRTtFrNKmVsTWIDxHJbocN9lEb/ohwvo7uGnm94PCa+MeQoTicjUDp8ILTTwW1BosZKexAW6wrRstlOW+gdnD50PMIU7DNBaXMcN9WjT7NMTtpjhN7iFkcHlKsxx/NXPiXRr85HLxYTZKKlJxMgO9nfJ+ysU38CGBywsrbyBvNz+nsIYRMkOZUoKYi1ng4nczzipTL33tGICn3EpiO6iVV7Vy0jeNajonakc4rZ0QjnfWa2Ot5o4J1lkBskElgtnbxfKcrGkceEewB6OwUKrBhmuPQVsKXBkpmPjCY3HbL3md9IQRhrVRDRbccqw441gQRiQ3yjXUfLXwpxFV1VRUVFyqhZp33t5ehVCoRFeQIvSfeLkOLg3j0OXizZXFF6Y+NVqFcd+bl1/6/QaSk456K9/vnz+Ae/zVpN+mOO/GYnH7c4j4+yLvanCPkIrZHibEeQ38yLie+hy2DwmMB389ePi56KFEOzDjlGC3bN9lIURIC4zVdLTYZbwOfR9GcEP0I2oFUxUlgr6h/c9rmlVSE3U4HA7/AgAA//8DADlZ+rXpBQAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxTTW/bOBD9K8KcRYUSqQ/q1sQJNkhbB7V3u9gbP0YOEYU0KCptEPi/F5Sdtm572JOE0ejNm/fevIKTTwg9vNvvr5/RxQlyiHYpVbRqCBWkbLe07nnXc1Hwpu2qlv4HOdg7fIEeWq51qQUShg0SbmhNFBs0MQZFSUtDq5onTLmboH8FaQuDz1Zj4ad/MEzWO+jhs3XGf0mzpS306GdTBD/irZuidDqRWV1v7rbre3LTNh+vLy+PnRNOCaCwBnpoBlSdkTVphdCEa6FI14qWSMkGM2CdnsffrIsYnByLyTz+4GB8dBh1XxUVKyipBBxyMDLKRFvJCbcv+8RkkWmV6vlSXl77V3jGAH2Vvwl6hLvQo714mtRsR3Mx+t3Out2Vd4PdzUHGNDiHffB7DNHiItB6k/3gVNKCFlVTUQo5fJD6wTrMblfZekwbq6E1tKIVEbJrCGeKkY5VjHBa0ZbJTjdlInmHweH4E+oHq4Of/BCzk+7Z2Zwr76J1s5+n7NZF3J2I9nAjxwlTwxwCupjdy/iQ/SWnh8TUtKbWFaIpmWqpGrDuTFd3ujVN0zWD5rWmrKw155oNygiplGC64tWgOLYN5LDF8GSdHN/73S5p+X3e+YcVDnIe48bPYUnGZB4hh0vrZHg5dvw9oTl1fTya8Ya0BGocl4Wyk59Xo0UXz/RNaSq54YIawkslCW8HQSQySVTdCN4pZhpMsb4P3sw6/moZowxy+DS7dErZbTLri3Xka8N/ofoTN0y5ypYoCymVarQgjFEkvCkrIriQhCGTkg+a8UFADiuvHzFkyS9p3RnYepO9C/rBRtRxDmnNf5fZ6012P8o4+PB0dnbrOe7nmH1CYwPqiInFNswJ6saO+BvZt71PGvIOctgcr3HZ9n8e45V3k/8D/B8dhx7kHD0cDofDNwAAAP//AwDB5+uJuAQAAA==
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTy07cMBT9lcjrODi248TZwQyoCFEQjNqqOz9uGItgjxyHh9D8e+XMIEp3XcW68jk+j5t35NUToB6d7nbnz+DThEqU3DKihApMJK7bDWl63vVcVpIwSrr2NyqRu4I31KOWG1MbCZiBAMwtabBmg8HWgqxJbQlteOZUDxPq35FylYVnZ6AK0w+Ikwse9ein8za85LeVq8wYZlvFMMKln5LyJotZn99fbW5u8UUrvp+fnR1uTjBlgspZ1CMxgO6sanArpcHcSI27VrZYKTbYAZr8PcCcTxC9GqvJPn5qsCF5SKanFWUVwVSifYmsSirL1mqCzdsuK1liWud5uYyXY/+OniGinpYfgR7oTszoTkx4elLengzOu2mLSrSLYQcxOVgiWVyOo0ou+OL4yGp04BMq0c19cTuqNIT49CWn+4P1y/9wfq3M1nkoLtcZU3PLJbGY11ph3g4SK2AK60ZI3mlmBeTaVsEn5+cwT8WlT/AQF5WoRxdqnOCg7zSarUtg0hyz9F8iA+HVpVWweUBQiW5jsLNJxWfcNalIxQjLHHPazam4A+simATZ0ybOR/p/IFRQkhlXc4zgU3Gr0rb4pqZtvmBb2xgKYGumW6IHaDrbNZ1prRCdGAxvDGF1Yzg3bNBWKq0lM5TTQXNoRdad2y2WhSINlxKswMKQBnMuNdaSMtxo0tq2YVTorP4z1uJmzDg9tJZQQrFUncCcaYY7RhnmhJKWqc6IOu/ORyTHxnmHSnQF0cP4l+VrZ2KYwpCKY/XFlxDuZp9/1mJZgxfn8esS/jqYR4hFLk85n/fy2Nd+v9//AQAA//8DAB8jFwPzAwAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUS2/dNhCF/4rAtSjzJT60S2IHdY3URmw0RXdDcmQL0aUuKMppYNz/XlC+gZvusiMGPMPDj4fzQhIckAzk3fF49YyprKQlZdpLgglNmaPcPLB+UHboWccsF9rwv0lLphv8TgZiVAg8OKQSNVIVWU+9HAONER1nPDLRq9oTHlcyvBCYuojPU8BuWf/EvE5LIgP5MqW4fKtnw9SFedlil5cZr9NaIIVq5vLq/ubh9o5+NPqPq/fvX3euuNYG3RTJQKKzIVghqTGipwowUDcCUAAZIcKoneOvsikVzAnmbo1f3zzEpSQsYRCdkB2jwpFTSyIUqLY9rPjw/Vid7Jgua73dy/tyeCHPmMkg2h9AX9tdhHm6KMtxxmecj5BXzBdhORwgRdKSY16OmMuEO5r9tvMMZVpScz7swzxhKqQlt/fN3QxlXPLhJ173rwiuf4HAM2ZPBiJZL7UxqKSU4LQXDKPRQWvhAzAzeoE8IOudMo4bxqUTzMleG2siMBdISx7nxcP8+7rzc4J5G5kRWqBCJ5TWLjCUFsfeIIugtdc2uCitEcpGK8EBcOfRBKdDDcknCE9Twub6kgxEcxWVY5Eq7oEqMzoKKIH6XjtlvYwaq+bDksqUtmVbm+tU8DHv/MhAPsK84iu5dzk8TQVD2XKF+peuwru8xC2U5i0BnHWsk0xW0VaOW2k+Y5wyhoIV70Pezv3+JxFaMFatbDljKs0dlKfmN1if6oZoYh8EYuTSG+ZH7G20vQ0mam31GFQfmOR9UCrI0UcH3jsZhBKjV2g0aQnWwDV7xsMYjRo9Uo9CUqUsp1b3hkouNToEiLuRN47N7Vx1fjSRCSaoA6upkl5SK2sHJpiRYIPmNc4/kJzDpyxpyQ3mhPN/rvxpCnlZl7E05xQ2P0H4vKU6P5o9kd+mRP/ZaV8u4Svmpr4WTKl+lfMDnVpyQFi3jId9/Awv5K7+k+Zhn0JCdZpz05L7Arlsx3NZ6c4xrU6n0+lfAAAA//8DAMJGy6DDBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUy27dNhCGX0XgWpR5v2iXxA5qGKmN+CAtuuNlFBPRoQ4oymlgnHcvKJ/ATXfdEQPO8J9vfs4Lyu4IaETvTqebZ8h1RT2qaQ8xwhQmFlN9IHIUZpRkoMRYQsVfqEfpDn6gEWkRAg0WMAcFWEQisedTwDGCpYRGwqRoNd3XFY0vyKUhwnMKMCzrFyhrWjIa0R8px+V7e9ulIczLFoeyzHCb1+pyaGKubx7vDvcP+KNWv9+8f/96c4W1FRhSRCOK1oRgGMdaM4mFg4Dt5Bx2jkcX3aSspa9pKVco2c3DGr+9aYhLzVDDyAbGB4KZReceRVddk+3dCocfp6Zkx3Td4v0e3o/jC3qGgkbW/wT6Wu4qzOlq3fwMzzCfXFmhXIXleHQ5oh6dynKCUhPsaJ6h+GVNtVENQTtjKI3GaSF1NFwyriMnEA3RMtponRJqYkHKKXpjtZhEZFREBUJ4jXq0w5tnV9OSu4v2D3OCXFGP7h+7h9nVaSnHX/A/vhK9/R9Am2w0Ik4kV1qD4Jw7qzwjELUKSjEfHNGTZ0ADEGmFtlQTyi0jlkuljY6O2IB69MmFp5Shu71GI1JURGFJxIJ6h4WeLHbAHfZSWWE8b42iHn1Yck15W7a1u80Vvpa9XzSij25e4bXTdyU8pQqhbqVB+FO1xIeyxC3U7s0AlAxk4IS3pK2ettp9hpgKhAoNx6Fsl3r/SWGKEdKkbKVArt2Dq0/db259aheijjIwgEi518RPIE000gQdlTJqCkIGwqkMQgQ++Wid95YHJtjkBWiFegTNb91u8TBJyi112BhtsIheYi8Nw0QqYo0xOoo2kTeO3f3c8vykI2GEYeuMwoJ7jg1nHAvCiObOBEWbm38iuZhFGNSjOygZ5n+1/CmFsqzLVLuLa7pfIHzeclsf3e6g7ynjv3fa10v4BqVr03Ipt59yGdC5R0dw61bguG+f8QU9tG/SHfYlxMSgKNU9eqyu1O10CQs1WKLE+Xw+/wMAAP//AwC8ws6OwgQAAA==
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoyUTW/bOBCG/4owZ1GhSOrz1sQJNsi2Tmu3u9gbRY4cIgppkFS6QeD/vqDsbOp2D3sTRqN3nnlnRq9g5RNCDx/2++tntDFADtEsIUZZTWhHymZLq160fUWLsmmrqhJ/QQ7mDl+gh0YoVaoOCccaidC0IgMfFdEau5KWmrJKJE25C9C/gjSFxmejsHDhG/pgnIUe/jBWu++ptjSFmtysC+8mvLUhSqsSzOp6c7dd35Obpv50fXl5zAwYkkBhNPSgu1aplnHSNKwiQqIi3SglkZJrqeVYd115/MzYiN7KqQj68Z1Bu2gxqp4VjBeUsA4OOWgZZcIeZMDtyz6RLDatUjxfwstj/wrP6KFn+ZuhR7kLNZmLpzDMZtIXk9vtjN1dOTua3exlTIVz2Hu3Rx8NLgatN9k7U0kLWrCaUQo5fJTqwVjMblfZekodD2OjKaOMdLKtieADJy1nnAjKaMNlq+oyQd6htzj9oPrRKO+CG2N28j07q3PlbDR2dnPIbm3E3Qm0hxs5BUwJs/doY3Yv40P2mwwPiVQ3ulIMUZd8aOgwYtXqtmpVo+u6rUclKkV5WSkhFB8H3clh6Lhigo2DwKaGHLbon4yV0+9ut0te/lvv/MUKRzlPceNmv2xG0I+Qw6Wx0r8cM74G1KesT8dhvCktCzVNS0PZaZ5Xk0Ebz/yFHupSaNFRTUQ5SCKasSMSuSRDVXeiHbiuMa31vXd6VvHnkXHKIYcvs02nlN2mYX03lvxdi59Qf2DDtFfZssp1Q5EPoyZIO0GEGJFIHCnRVOMoq6qsaAc5rJx6RJ+leUljz8TWm+yDVw8mooqzT23+udReb7L7ScbR+aezs1vPcT/H7Atq41FFTBRbPyepGzPhL7BvfZ88FO1xb4J7y/2GfnDBxPSL+DwbTAZvjue62PE/r/VM853pPzcCepBzdHA4HA7/AAAA//8DAL7u70zYBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUy27jOBD8FYFnUaFI6mVgDxknwQbZjI3Ys7vYGx/NmIhMGhSVTBDk3weU5YkzmMPeCLIfVdXVfENO7AEt0OXhcP0MLg4oR9FOV5TQGpMOl82WVAveLipS1C1rSMn/Qzmyd/CKFqjhSpWqA8ygBsw1qbBkRmGtoStJqQmteKopHge0eEPCFhqerYLCD39DGKx3aIH+sU77l9Rb2EL1ftRF8D3cuiEKpxKYq+vN3Xa1xjdN/fX6y5dj5ABDKlBYjRZId61SLWW4aWiFuQCFOyMEFoJpoYWpu648plkXITjRF4N++sCgfXQQ1YIWlBUE0w6950iLKBJsKQbYvh4Skkmmq3SfT9fTcfGGniGgBc1Pgh7LXajeXuwHOdpeX0QRHiGaIPbw4sMTPIse5egQ/AFCtDDps9pkH5BKUpCC1pQQlKN7oXbWQXZ7la36RFiaRhNKKO5EW2POJMMtowxzQknDRKvqMmG8g+CgP6t6b1Xwgzcxm2XPPvVZehetG/04ZLcuwmMQ8Zh3I/oBUI7Wo+ztsHsAoV+3/mFMb27sE5flGAK4mK1F3GV/imGXSOhGV4oC6JLJhkgDVavbqlWNruu2NopXirCyUpwrZqTuhJQdU5RTIzk0NcrRaoyHMZ70/34GYhvsfg/6A8F20njdi2h82N9qcNEam0ZziphM1fcTq2yuuewtuPhJZLRAdck174jGvJQC88Z0WAATWFZ1x1vJdA3J2kvvjH0cf+pEjNGcmapta9nJkgA1TctJYyTRyjDV6UpCQxvStNB2ihBZ8bKVnSIayorrxC54Par4qxUYYShHD6NLG5rdJtov1uHvNf9QZB189Mr3aWC2h83rEGH/u6kt/f7gBxsTfzOP9tsA996l1/kPmDWbky99PAuGtAjZtHtJFg2c4k7VDHNtGiwp0biuW97UXDDQHcrRlVdPELLkMGHdNJSTqVab7DKonY2g4hhS638nVsd53px25kOQ4uv1dukDXB4O+Xz7x3NXJAevNtnJAJ/+lm9Dit75IbGIYTzz0QZ6c4J15qaj8bIH0DaAitPT9pg4a/Q7h12GaI1QcUhr8JdXkzFmp80hv/Y7G8DP7GPzsyHMppgr8XaqM/2AkxX+1wf4/v7+/gMAAP//AwCxdSWE9QUAAA==
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTU/cMBD9K9Gc4+DYjvNxoyxVEWpBsGqr3ib2pFgEZ+U4fAjtf6+cXUTprRfLGvnNvPfm+RU8PhB0cLrbnT+SjzPkEN1aElxoxltW1ltedarpKl00Uopa6l+Qg7ukF+igVsaUpiUmSRNTllesl4Nh1lJb8tJyUanUE3/P0L0CusLSozNUTPN3CrObPHTww3k7PaXZ6AozTostwjTShZ8jepPIbM5vL7dX1+xzrb+df/p0eDnTnBoUzkIHtm2MaYRkdS0qppAMawdEhigtWhx025YHmPORgsexmO39Owc7RU/RdKIQsuBMtLDPwWLERLvHmbYvu8RktWmT6vlaXq/dKzxSgE7kb4Ye2p2Y0Z08zP3iRntyOCPO90npLkw7CtHR6ssqdRwxuslnx0lnoyMfIYer2+x6xDhM4eGDWbcH/Rf/IX+bpp8/k1ki2bNp8RE6UAJy+IrmznnKLjbQgS6VVS23TJU9MlUPLUOSyPpKt6rppdWUtno2+ej8Mi1zduEj/Q4rf+jgM44zHZifBnPnIpm4hCTqp07A6zDZxcTs3f6SF7yQXCbQEndLzG7IukAmUpK3Dcux3z8QoQXnicoSAvmYXWO8y77gfJce2NpWRhDZUvY17weqGttUjamt1o0ejKoMl2VllDJy6G2Lfd9KI5QYekW1hhwobTtbA9YS15wImUbNmdKVYNhzYkZz5FXbN7ZMmXj3MbsaE64fassFF6zFRjMle8kaKSRTXPBaYmP0inuz5Lh81UAOlxQ8jX9J/upMmOZpiNkxBdkHE24Wnz5vtibiyXn2vLq9mcw9hSxtC51POT0uaL/f7/8AAAD//wMAd4cViwMEAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTXW/bIBT9KxbPwcWAMfitbVotqrpGbbRNe8Nw3aA6EGHcD1X57xNOqq5725t1xTn3fFy/I693gFp0vt9fPYNPI1qg5OYRJVRgonDVbEjdctnWopSMM0bVb7RA7gbeUIsabkxlFGAGAjC3pMYd6w22FlRFKktozTOnfhxR+460Ky08OwNlGH9AHF3wqEU/nbfhJe/WrjRDmGwZwwArPybtTRazvHq42dyt8XUjvl9dXBxfjjBmgtJZ1CKrpDGSMtw0tMZcg8Gq1xprzay2uhdKVUeY8wmi10M52qdPDTYkD8m0tKSsJJgqdFggq5POsjs9wuZtn5XMMS3zfDGP58/2HT1DRC1dfAR6pDszgzszYbfT3p71zrtxixZoH8MeYnIwRzK7HAadXPDFacnl4MAntEB3D8V60KkPcfclp4ej9dV/OL/VZus8FKslapGouOWKWMyrTmPe9AprYBp3tVBcdswKyLVdBp+cn8I0Fiuf4DHOKlGLrvUwwlHfeTRbl8CkKWbpv0QGwqtLl8HmQd69jsFOJhWfcVekJCUjLHNMaT+l4h6si2ASZE+bOJ3o/4FQQQnJyqYYwadirdO2+KbHbX5gG1sbCmAr1jWk66GWVtbSNFYIKXrDa0NYVRvODes7q3TXKWYop33HoRFZd263mA+qMY1QnQbMOasxZ12HOwUWV5KQCqSESvdfYi3uhozr+sYSSihWWoqMY1gyyjAnlDRMSyOqfDsfkZwa5xIt0A1ED8Nflm+diWEMfSpO1RdfQriffP5Zi/kMXpzHr3P4y2CeIBa5PO18vstTX4fD4fAHAAD//wMA/WXCnPMDAAA=
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUy27kIBD8FYvz4GDA2PiWpzaKsok2o+xqbzzaCYoDI8B5KJp/X+GZKDun1V4QancX1VUlfyCvngEN6HizOX8BnxNaoeyWEiVUYCJx061JO/B+aEXdU0lb2v1GK+Su4B0NqOPGNEYCZiAAc0tarNlosLUgG9JYQlteMNVDQsMHUq628OIM1CHdQ0wueDSgn87b8FreVq42U5htHcMElz5l5U0hc3Z+d7W+ucUXnfh+fnKy60yQCkDtLBqQlb0xPWW462iLuQKD5agUVopZZdUopGx2Y85niF5NdbJPXxxsyB6yGWhNWU0wlWi7QlZlVWhrlWD9vilMFpnOSn21lJfr8IFeIKKBrj4F3cEdmckdPSc9u8ke7c6s0lNazlGZHOI7WqFNDBuI2cGi0XFK8Kyn97VKTxe7nnJN529g5gz2NMw+owExgVZo0WiaVHbBV3uKp5MDn5ePOTqfnPkXVEG6uatuJ5XHEJ8PLLnbqXz5HyJfK/PoPFSXZwW74ZZLYjFvtMK8GyVWwBTWrZC818wKKAk5DT47P4c5VZc+w0NcVkIDulBTgh2/42geXQaT51j2/CXK4G0Mdja5+rKyITWpGWFlaM6bOVc/wLoIJkNZYh3nPd794QgVlJBCZY4RfK5uVX6svqn0WBpsZ1tDAWzDdEf0CG1v+7Y3nRWiF6PhrSGsaQ3nho3aSqW1ZIZyOmoOXREYSnKqJaySso522mA7tgJzYhusjBW4oVJT23R2pPRAx+pmKnN67CyhhGKpeoE50wz3jDLMCSUdU70RTcnlpyT7PPAerdAVRA/TXytfOxNDCmOu9l5XByL8mH35EVSL76/O47dF7bNgniBWxS3lfMn83qDtdrv9AwAA//8DAJ9uh65PBAAA
@@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRVTW/jNhD9KwKvlbQSKZGSgD1kHWcbLPKB2OgWvQ3JYcxGIQ2KyjYN/N8LyknX2UUPvdjSSDPz5r031Atx8IhkIGf7/foJXZxITqJdQrSivKj6ohbbqh2abmh52bGu5XX/B8mJ/YLPZCCiUapWPRYMORaNrtpCMqMKrbGvq1pXtG1STbifyPBCwJYan6zC0k+/YZisd2QgX63T/lvqDbZUo591GfyIl26K4FQCc77efNne3BYXgl+vP306vjnhlAqUVpOB6L5TqqOsEIK2RQOoit4AFABMgwbD+74+plkXMTgYy0k/fMegfXQY1UBLysqqoD055ERDhARbwoTb531CstB0nuL5El4uhxfyhIEMNH8j9Fjugxrth8dJznbUH5ZfkpN98HsM0eLCyDLkOEK03mWvPVajRRdJTj6llM2sFE4TGcgFjBO+hdfu3jq8CPCI33x4uD72La/XW5KT2+D/RBVvIe7IQJhRnTZS1dD00kjUPa9awB5MI5vOaM1qZRStTcWFkLQxrNNSAuei1VJSSnJys8luR4jGh8d3gm2OGlz+Dwk2Z6u1Azmi/nGkLYR7jGQgLW8lMtNRLjlo6FlnakDWayUZb7GTQncoBKMMVKuqrla1qFvoRdsKzmjqcjltIjgNo3e4/gvVnAgmA9mGOfW7ArWzDrPLczIQXje66StdNLWEohGmLwAZFLLlfdNJpjkmE1+AHeeAK4h470Ny//X8GZNOK++idbOfp+zSRbwPi5wn491ssrOgdjaiinNIQv3OU8nb4PWsYvbdh3VVViWrWEqa436O2R1qG1DFha9X+DebH1Mop1WVoMwhoItZkj77Faakf62FbhVF1DWTopIG2053baeE5rzjRjWtqljdqqZRzEjdg5Q9U7ShRjYoOMkJJttny6Y1XS8qo6DoIFHGelOAkrLg0vTQiKpjlXjHcHYzpjxphK5oRYseOl40TLKiY5QVTUUrwaBTvIb35j4ZsSt5WTclE91CzRtvrwvTdCQn6xB8WPk5nWLD6d2lMz57yVb+cW9HDNnHLM+upqXPZ3QYYDwNrZ9gnI/reBp9s9C77AD73RLYwvSwXGz0wx1OfnzCMC2Ba4wbfXy2uCX7mNV5tqSvdqhen0C0T7j1flQ7sMceK6/xzMH4PNljpTv42x/Bf92b5f9s2l8vFfPsJu6WybIDyckXDA7HE4dcWRX85E3MXjc3e+eZE8rP7bQf4fkn5n9BwSpTa5mk/Y7+50W+m136gGTLifDNuuKvxejnXj1gyNKigHXprPw342oeo93uAoJGfeU1/lj0kJNHhGkO+Lh8opZT02FYYJzPx2W7dFd2HO2Eyjs9kYFz3pSM0/YV7n++JxpaVqzmh8Ph8A8AAAD//wMADw4oQRAHAAA=
@@ -0,0 +1,18 @@
{
"Microsoft.NET.Workload.Emscripten.Current": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net6": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net7": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net8": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Emscripten.net9": "10.0.111/10.0.100",
"Microsoft.NET.Sdk.Android": "36.1.69/10.0.100",
"Microsoft.NET.Sdk.iOS": "26.5.10315/10.0.100",
"Microsoft.NET.Sdk.MacCatalyst": "26.5.10315/10.0.100",
"Microsoft.NET.Sdk.macOS": "26.5.10315/10.0.100",
"Microsoft.NET.Sdk.Maui": "10.0.20/10.0.100",
"Microsoft.NET.Sdk.tvOS": "26.5.10315/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.Current": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net6": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net7": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net8": "10.0.111/10.0.100",
"Microsoft.NET.Workload.Mono.ToolChain.net9": "10.0.111/10.0.100"
}
@@ -13,41 +13,16 @@ namespace Distribution.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( DropForeignKeyIfExists(migrationBuilder, "DeliveryRuns", "FK_DeliveryRuns_Routes_FK_RouteId");
name: "FK_DeliveryRuns_Routes_FK_RouteId", DropForeignKeyIfExists(migrationBuilder, "DeliveryRuns", "FK_DeliveryRuns_Warehouses_FK_SourceWarehouseId");
table: "DeliveryRuns");
migrationBuilder.DropForeignKey( DropForeignKeyIfExists(migrationBuilder, "DeliveryTasks", "FK_DeliveryTasks_Orders_FK_OrderId");
name: "FK_DeliveryRuns_Warehouses_FK_SourceWarehouseId", DropForeignKeyIfExists(migrationBuilder, "LoadingSheetItems", "FK_LoadingSheetItems_LoadingSheets_FK_LoadingSheetId");
table: "DeliveryRuns"); DropForeignKeyIfExists(migrationBuilder, "LoadingSheets", "FK_LoadingSheets_Warehouses_FK_SourceWarehouseId");
DropForeignKeyIfExists(migrationBuilder, "Payments", "FK_Payments_Sales_FK_SaleId");
migrationBuilder.DropForeignKey( DropForeignKeyIfExists(migrationBuilder, "Payments", "FK_Payments_Stores_FK_StoreId");
name: "FK_DeliveryTasks_Orders_FK_OrderId", DropForeignKeyIfExists(migrationBuilder, "SaleReturns", "FK_SaleReturns_Orders_FK_OrderId");
table: "DeliveryTasks"); DropForeignKeyIfExists(migrationBuilder, "SchemeRedemptions", "FK_SchemeRedemptions_Orders_FK_OrderId");
migrationBuilder.DropForeignKey(
name: "FK_LoadingSheetItems_LoadingSheets_FK_LoadingSheetId",
table: "LoadingSheetItems");
migrationBuilder.DropForeignKey(
name: "FK_LoadingSheets_Warehouses_FK_SourceWarehouseId",
table: "LoadingSheets");
migrationBuilder.DropForeignKey(
name: "FK_Payments_Sales_FK_SaleId",
table: "Payments");
migrationBuilder.DropForeignKey(
name: "FK_Payments_Stores_FK_StoreId",
table: "Payments");
migrationBuilder.DropForeignKey(
name: "FK_SaleReturns_Orders_FK_OrderId",
table: "SaleReturns");
migrationBuilder.DropForeignKey(
name: "FK_SchemeRedemptions_Orders_FK_OrderId",
table: "SchemeRedemptions");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "OrderItems"); name: "OrderItems");
@@ -58,57 +33,20 @@ namespace Distribution.Migrations
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "Sales"); name: "Sales");
migrationBuilder.DropIndex( DropIndexIfExists(migrationBuilder, "Payments", "IX_Payments_FK_SaleId");
name: "IX_Payments_FK_SaleId", DropIndexIfExists(migrationBuilder, "LoadingSheets", "IX_LoadingSheets_FK_SourceWarehouseId");
table: "Payments"); DropIndexIfExists(migrationBuilder, "DeliveryRuns", "IX_DeliveryRuns_FK_RouteId");
DropIndexIfExists(migrationBuilder, "DeliveryRuns", "IX_DeliveryRuns_FK_SourceWarehouseId");
migrationBuilder.DropIndex( DropColumnIfExists(migrationBuilder, "Payments", "FK_SaleId");
name: "IX_LoadingSheets_FK_SourceWarehouseId", DropColumnIfExists(migrationBuilder, "LoadingSheets", "DispatchConfirmedAt");
table: "LoadingSheets"); DropColumnIfExists(migrationBuilder, "LoadingSheets", "DispatchConfirmedBy");
DropColumnIfExists(migrationBuilder, "LoadingSheetItems", "QuantityReceived");
migrationBuilder.DropIndex( DropColumnIfExists(migrationBuilder, "LoadingSheetItems", "QuantityResolved");
name: "IX_DeliveryRuns_FK_RouteId", DropColumnIfExists(migrationBuilder, "DeliveryRuns", "ActivatedAt");
table: "DeliveryRuns"); DropColumnIfExists(migrationBuilder, "DeliveryRuns", "FK_RouteId");
DropColumnIfExists(migrationBuilder, "DeliveryRuns", "FK_SourceWarehouseId");
migrationBuilder.DropIndex( DropColumnIfExists(migrationBuilder, "DeliveryRuns", "RideType");
name: "IX_DeliveryRuns_FK_SourceWarehouseId",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "FK_SaleId",
table: "Payments");
migrationBuilder.DropColumn(
name: "DispatchConfirmedAt",
table: "LoadingSheets");
migrationBuilder.DropColumn(
name: "DispatchConfirmedBy",
table: "LoadingSheets");
migrationBuilder.DropColumn(
name: "QuantityReceived",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "QuantityResolved",
table: "LoadingSheetItems");
migrationBuilder.DropColumn(
name: "ActivatedAt",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "FK_RouteId",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "FK_SourceWarehouseId",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "RideType",
table: "DeliveryRuns");
migrationBuilder.RenameColumn( migrationBuilder.RenameColumn(
name: "FK_OrderId", name: "FK_OrderId",
@@ -957,5 +895,66 @@ namespace Distribution.Migrations
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Restrict); onDelete: ReferentialAction.Restrict);
} }
private static void DropForeignKeyIfExists(MigrationBuilder migrationBuilder, string tableName, string constraintName)
{
migrationBuilder.Sql($"""
SET @sql = IF(
EXISTS(
SELECT 1
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = '{tableName}'
AND CONSTRAINT_NAME = '{constraintName}'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
),
'ALTER TABLE `{tableName}` DROP FOREIGN KEY `{constraintName}`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
""");
}
private static void DropIndexIfExists(MigrationBuilder migrationBuilder, string tableName, string indexName)
{
migrationBuilder.Sql($"""
SET @sql = IF(
EXISTS(
SELECT 1
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{tableName}'
AND INDEX_NAME = '{indexName}'
),
'DROP INDEX `{indexName}` ON `{tableName}`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
""");
}
private static void DropColumnIfExists(MigrationBuilder migrationBuilder, string tableName, string columnName)
{
migrationBuilder.Sql($"""
SET @sql = IF(
EXISTS(
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{tableName}'
AND COLUMN_NAME = '{columnName}'
),
'ALTER TABLE `{tableName}` DROP COLUMN `{columnName}`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
""");
}
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"Urls": "http://localhost:5089", "Urls": "http://localhost:5089",
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution_db;User=kalana;Password=kalana@hexdive" "DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution;User=kalana;Password=kalana@hexdive"
}, },
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
@@ -684,15 +684,33 @@ appsettings.Development.json
--- ---
## Open Items (must resolve before coding the relevant phase) ## Open Items
### Resolved during implementation
| # | Item | Resolution |
|---|---|---|
| 2 | Cascade when Confirmed ShopPO is cancelled and Invoice already exists | Cancel is rejected (400) if a linked Invoice exists. Invoice must be voided separately before the ShopPO can be cancelled. No automatic cascade. |
| 3 | Cascade when Confirmed ShopPO is cancelled and it's in a LoadingSheet | Cancel is rejected (400) if the ShopPO appears in any Planned LoadingSheet. Operator must remove it from the sheet first. |
| 4 | `FinalizeInvoiceRequest` payload — free issues field shape | Deferred to a later pass. v1 `POST /api/invoices/{id}/finalize` takes no body and locks the invoice as-is. |
| 5 | Who can confirm a ShopPO? | No role restriction in v1 — any user can confirm. Gating deferred until the auth layer is implemented. |
### Still open
| # | Item | Blocks | | # | Item | Blocks |
|---|---|---| |---|---|---|
| 1 | Who holds `loading.confirm` permission? | Phase 9 — ConfirmAsync auth guard | | 1 | Who holds `loading.confirm` permission? | Permission seeding (`loading.populate`, `loading.confirm`) — explicitly noted as **Pending** in the API reference. Must decide role before seeding. |
| 2 | Cascade when Confirmed ShopPO is cancelled and Invoice already exists | Phase 7 — CancelAsync |
| 3 | Cascade when Confirmed ShopPO is cancelled and it's in a LoadingSheet | Phase 7 — CancelAsync | ### New items — identified during frontend refactor (2026-09-17)
| 4 | `FinalizeInvoiceRequest` payload — free issues field shape | Phase 6 — FinalizeAsync |
| 5 | Who can confirm a ShopPO? (agent, office, either?) | Phase 7 — ConfirmAsync permission | | # | Item | Area |
|---|---|---|
| 6 | `order-return-edit-dialog.tsx` — file not verified; client imports it but it was not reviewed during the refactor pass. Needs to be checked for old `orderId`/`OrderResponse` references. | Returns |
| 7 | `sales-visits/` components — any components in this section that interact with sales/orders need auditing against the new ShopPO/Invoice types. | Sales Visits |
| 8 | SysConfig management page not built — the backend has a `SysConfig` model and the `InvoiceCreationMode` config key is read by `ShopPOController`. A UI to view and toggle system config is missing. | Config |
| 9 | InvoiceCreationMode (ModeA vs ModeB) UI branching — the ShopPO confirm button calls `confirm` or `confirmAndCreateInvoice` depending on mode, but the mode is not currently read from the backend. The UI should fetch `GET /api/sys-config` and gate the action accordingly. | Sales / Invoice |
| 10 | Loading Sheet item population UI — `PUT /api/loading-sheets/{id}/items` exists in the API layer but no UI for populating/editing items on a Planned sheet has been built. | Dispatch |
| 11 | `PaymentMethod` enum is missing `Cheque` — per `invoice_collection.md`, the final design includes `Cash`, `Cheque`, `Credit`. The frontend only has `Cash` and `Credit`. Cheque-specific fields (`ChequeNumber`, `ChequeDate`) and `EntryMode` (`RealTime`, `DayEndManual`) are also unimplemented. | Payments |
--- ---
@@ -0,0 +1,978 @@
# Frontend API Reference — ERP Distribution (Post-Refactor)
> Last updated: 2026-09-17
> Base URL: `https://<host>/api`
> All responses are JSON. Enum fields are serialized as **strings** (e.g. `"Pending"`, `"ModeB"`).
> All `Guid` fields are UUID v4 strings. All `DateTime` fields are ISO 8601.
---
## Table of Contents
1. [Pagination](#pagination)
2. [Enums Reference](#enums-reference)
3. [ShopPO](#shoppo)
4. [Invoice](#invoice)
5. [Payment](#payment)
6. [Ride (DeliveryRun)](#ride-deliveryrun)
7. [Loading Sheet](#loading-sheet)
8. [Delivery Tasks](#delivery-tasks)
9. [Returns](#returns)
10. [Schemes](#schemes)
11. [System Config](#system-config)
12. [Key Relationships & Flows](#key-relationships--flows)
---
## Pagination
All list endpoints return a `PagedResponse<T>`:
```json
{
"page": 1,
"pageSize": 20,
"total": 84,
"items": [ /* T[] */ ]
}
```
Default page size is `20`. Pass `?page=2&pageSize=50` to paginate.
---
## Enums Reference
| Enum | Values |
|------|--------|
| `ShopPOStatus` | `Pending`, `PartiallyFulfilled`, `Fulfilled`, `Cancelled` |
| `InvoiceStatus` | `Draft`, `Printed`, `Finalized` |
| `PaymentStatus` | `Pending`, `PartiallyPaid`, `Paid` |
| `PaymentMethod` | `Cash`, `Credit` |
| `RideStatus` | `Open`, `Cancelled`, `Closed` |
| `LoadingSheetStatus` | `Planned`, `Confirmed` |
| `DeliveryTaskStatus` | `Pending`, `Delivered`, `PartiallyDelivered`, `Failed` |
| `ReturnMode` | `CreditDeduction`, `ItemExchange` |
| `ReturnReasonCode` | `Expired`, `Damaged`, `OrderError`, `Other` |
| `CancelRequestType` | `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`, `Other` |
| `InvoiceCreationMode` | `ModeA`, `ModeB` |
| `SchemeRuleType` | `SlabDiscount`, `FlatPercent`, `BuyXGetY`, `FreeBundle` |
---
## ShopPO
A ShopPO is a shop-facing purchase order raised by a sales agent on behalf of a store.
### Status Flow
```
Pending → PartiallyFulfilled → Fulfilled
↘ Cancelled
```
### `GET /api/shop-pos`
List all ShopPOs with optional filters.
**Query params:**
| Param | Type | Description |
|-------|------|-------------|
| `agentId` | `Guid?` | Filter by the agent who raised the PO |
| `routeId` | `Guid?` | Filter by the store's route |
| `shopId` | `Guid?` | Filter by store |
| `confirmed` | `bool?` | `true` = confirmed only, `false` = unconfirmed only |
| `page` | `int` | Default `1` |
| `pageSize` | `int` | Default `20` |
**Response:** `PagedResponse<ShopPOResponse>`
```json
{
"page": 1,
"pageSize": 20,
"total": 12,
"items": [
{
"id": "uuid",
"storeId": "uuid",
"storeName": "Colombo Mart",
"raisedById": "uuid",
"invoiceId": "uuid | null",
"totalAmount": 15000.00,
"status": "Pending",
"confirmedAt": "2026-09-17T10:00:00 | null",
"confirmedBy": "uuid | null",
"fulfilledAt": "2026-09-17T14:00:00 | null",
"items": [
{
"id": "uuid",
"itemId": "uuid",
"itemName": "Milo 400g",
"quantity": 10,
"quantityFulfilled": 0,
"unitPrice": 850.00,
"isActive": true
}
],
"isActive": true,
"createdAt": "2026-09-17T08:00:00"
}
]
}
```
### `GET /api/shop-pos/{id}`
**Response:** `ShopPOResponse` (same shape as above, single object) or `404`.
---
## Invoice
An Invoice is the billing document generated from a confirmed ShopPO. There is at most one Invoice per ShopPO.
**InvoiceCreationMode** (system config) controls whether the invoice is:
- **ModeA** — auto-created when the ShopPO is confirmed
- **ModeB** — created as a separate explicit step (default)
### Status Flow
```
Draft → Printed → Finalized
```
Once Finalized, the invoice is locked. Payments can be collected at any status.
### `GET /api/invoices`
**Query params:**
| Param | Type | Description |
|-------|------|-------------|
| `agentId` | `Guid?` | Agent who raised the ShopPO |
| `routeId` | `Guid?` | Store's route |
| `shopId` | `Guid?` | Store |
| `status` | `InvoiceStatus?` | `Draft`, `Printed`, `Finalized` |
| `page` | `int` | Default `1` |
| `pageSize` | `int` | Default `20` |
**Response:** `PagedResponse<InvoiceResponse>`
```json
{
"page": 1,
"pageSize": 20,
"total": 5,
"items": [
{
"id": "uuid",
"shopPOId": "uuid",
"storeName": "Colombo Mart",
"status": "Draft",
"paymentStatus": "Pending",
"createdBy": "uuid",
"printedAt": null,
"printedBy": null,
"finalizedAt": null,
"finalizedBy": null,
"totalAmount": 15000.00,
"isActive": true,
"createdAt": "2026-09-17T08:30:00"
}
]
}
```
### `GET /api/invoices/{id}`
**Response:** `InvoiceResponse` (single) or `404`.
> **Note:** The detail response from the repository also loads `Payments`. The current `InvoiceResponse` DTO does not yet expose payments — use `GET /api/invoices/{id}/payments` for that.
---
## Payment
Payments are collected against an Invoice. Multiple payments can be made on one invoice (e.g. partial cash collection).
### `GET /api/invoices/{invoiceId}/payments`
Returns all payments for the invoice.
**Response:** `PaymentResponse[]`
```json
[
{
"id": "uuid",
"invoiceId": "uuid",
"amount": 5000.00,
"method": "Cash",
"collectedAt": "2026-09-17T15:00:00",
"collectedBy": "uuid",
"createdAt": "2026-09-17T15:00:00"
}
]
```
Returns `404` if the invoice does not exist.
### `POST /api/invoices/{invoiceId}/payments`
Record a payment against an invoice.
**Request body:**
```json
{
"amount": 5000.00,
"method": "Cash",
"collectedByUserId": "uuid | null"
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `amount` | `decimal` | Yes | Must be > 0 |
| `method` | `PaymentMethod` | Yes | `Cash` or `Credit` |
| `collectedByUserId` | `Guid?` | No | Defaults to authenticated user |
**Response:** `PaymentResponse` or `404` if invoice not found, `400` on business rule violation.
---
## Ride (DeliveryRun)
A Ride represents a vehicle dispatch for a day. The driver moves along a route making deliveries.
### Status Flow
```
Open → Closed
↘ Cancelled
```
### `GET /api/rides`
**Query params:**
| Param | Type | Description |
|-------|------|-------------|
| `status` | `RideStatus?` | `Open`, `Cancelled`, `Closed` |
| `driverProfileId` | `Guid?` | Filter by driver |
| `vehicleId` | `Guid?` | Filter by vehicle |
| `page` | `int` | Default `1` |
| `pageSize` | `int` | Default `20` |
**Response:** `PagedResponse<RideResponse>`
```json
{
"items": [
{
"id": "uuid",
"status": "Open",
"vehicleId": "uuid",
"vehicleNumberPlate": "WP CAB 1234",
"driverProfileId": "uuid",
"driverFullName": "Kamal Perera",
"meterstart": 12450,
"meterend": 0,
"startedAt": null,
"closedAt": null,
"createdAt": "2026-09-17T06:00:00"
}
]
}
```
### `GET /api/rides/{id}`
**Response:** `RideResponse` or `404`.
### `POST /api/rides`
Create a new ride.
**Request body:**
```json
{
"vehicleId": "uuid",
"driverProfileId": "uuid",
"meterstart": 12450
}
```
**Response:** `201 Created` with `RideResponse`. `400` on validation error.
### `PUT /api/rides/{id}/driver`
Re-assign the driver on an Open ride.
**Request body:**
```json
{
"driverProfileId": "uuid"
}
```
**Response:** `RideResponse`. `404` if ride not found. `400` if ride is not Open.
### `POST /api/rides/{id}/close`
Close a ride and record the ending odometer.
**Request body:**
```json
{
"meterend": 12890
}
```
**Response:** `RideResponse`. `400` if ride is not Open.
### `POST /api/rides/{id}/cancel`
Cancel a ride.
**Request body:**
```json
{
"typeCode": "VehicleBreakdown",
"reason": "Engine overheating"
}
```
| Field | Type | Notes |
|-------|------|-------|
| `typeCode` | `CancelRequestType` | `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`, `Other` |
| `reason` | `string` | Free text reason |
**Response:** `RideResponse`. `400` if ride is not Open.
---
## Loading Sheet
A Loading Sheet records the items loaded onto a vehicle before a ride. It belongs to a Ride.
### Status Flow
```
Planned → Confirmed
```
Items can only be edited while the sheet is `Planned`. Confirming the sheet commits stock movements (Warehouse → Vehicle).
### `GET /api/loading-sheets`
**Query params:**
| Param | Type | Description |
|-------|------|-------------|
| `rideId` | `Guid?` | Filter by ride |
| `status` | `LoadingSheetStatus?` | `Planned`, `Confirmed` |
| `page` | `int` | Default `1` |
| `pageSize` | `int` | Default `20` |
**Response:** `PagedResponse<LoadingSheetResponse>`
```json
{
"items": [
{
"id": "uuid",
"fK_RideId": "uuid",
"status": "Planned",
"populatedBy": "uuid",
"populatedAt": "2026-09-17T07:00:00",
"confirmedAt": null,
"confirmedBy": null,
"createdAt": "2026-09-17T07:00:00",
"items": [
{
"id": "uuid",
"fK_ItemId": "uuid",
"itemName": "Milo 400g",
"fK_WarehouseId": "uuid",
"warehouseName": "Colombo Warehouse",
"quantityDispatched": 24.0,
"fK_ShopPOItemId": "uuid | null"
}
],
"orders": [
{ "shopPOId": "uuid" }
]
}
]
}
```
### `GET /api/loading-sheets/{id}`
**Response:** `LoadingSheetResponse` or `404`.
### `POST /api/loading-sheets`
Create a new loading sheet for a ride. The ride must exist. Only one `Planned` sheet per ride is allowed.
**Request body:**
```json
{
"fK_RideId": "uuid"
}
```
**Response:** `201 Created` with `LoadingSheetResponse`. `400` if ride already has a planned sheet.
### `PUT /api/loading-sheets/{id}/items`
Replace all items on a `Planned` loading sheet. This is a full replace — any items not included are removed.
**Request body:**
```json
{
"items": [
{
"fK_ItemId": "uuid",
"fK_WarehouseId": "uuid",
"quantity": 24.0,
"fK_ShopPOItemId": "uuid | null"
}
]
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `fK_ItemId` | `Guid` | Yes | The item being loaded |
| `fK_WarehouseId` | `Guid` | Yes | Source warehouse for this item |
| `quantity` | `decimal` | Yes | Units to load |
| `fK_ShopPOItemId` | `Guid?` | No | Links the load line to a specific ShopPO line item |
**Response:** `LoadingSheetResponse`. `400` if sheet is not `Planned`.
### `POST /api/loading-sheets/{id}/confirm`
Confirm the loading sheet. Commits stock: moves each item from its specified warehouse to the vehicle. Sheet becomes `Confirmed` and can no longer be edited.
**Request body:** _(none)_
**Response:** `LoadingSheetResponse`. `400` if already confirmed.
---
## Delivery Tasks
Delivery Tasks are the per-stop delivery jobs attached to a Ride. Each task maps to one ShopPO.
> **Note:** Auto-generation of delivery tasks from route/ShopPO assignments is deferred and not yet implemented. Tasks must be created manually or via a future dispatch endpoint.
### Status Flow
```
Pending → Delivered
↘ PartiallyDelivered
↘ Failed
```
### `GET /api/rides/{rideId}/tasks`
List all delivery tasks for a ride, ordered by `Sequence`.
**Response:** `DeliveryTaskResponse[]`
```json
[
{
"id": "uuid",
"fK_RideId": "uuid",
"fK_ShopPOId": "uuid",
"storeId": "uuid",
"storeName": "Galle Mart",
"sequence": 1,
"status": "Pending",
"customerConfirmed": false,
"confirmedAt": null,
"note": null,
"items": [
{
"itemId": "uuid",
"itemName": "Milo 400g",
"quantity": 10,
"quantityFulfilled": 0,
"unitPrice": 850.00
}
],
"createdAt": "2026-09-17T08:00:00"
}
]
```
### `GET /api/delivery-tasks/{id}`
**Response:** `DeliveryTaskResponse` or `404`.
### `PUT /api/delivery-tasks/{id}/quantities`
Set the fulfilled quantity for each item in the task. Call this before confirming.
**Request body:**
```json
{
"items": [
{ "itemId": "uuid", "quantityFulfilled": 8 }
]
}
```
**Response:** `DeliveryTaskResponse`. `400` if task is already closed.
### `POST /api/delivery-tasks/{id}/confirm`
Mark the task as Delivered or PartiallyDelivered based on quantities set. Updates ShopPO fulfillment, creates stock movement (Vehicle → External), and records payment if applicable.
**Request body:** _(none)_
**Response:** `DeliveryTaskResponse`. `400` if task is not `Pending`.
### `POST /api/delivery-tasks/{id}/fail`
Mark the task as Failed with a reason note.
**Request body:**
```json
{
"note": "Store was closed"
}
```
**Response:** `DeliveryTaskResponse`. `400` if task is not `Pending`.
---
## Returns
Returns are recorded against a ShopPO during the same store visit. Effects (credit deduction or item exchange) are applied immediately — no approval step.
> **Note:** The Create endpoint (`POST /api/shop-pos/{id}/returns`) is currently stubbed (`501 Not Implemented`) while the return flow is being redesigned. `GET` and `PATCH` endpoints are fully operational.
### `GET /api/returns`
List all returns.
**Response:** `SaleReturnResponse[]`
```json
[
{
"id": "uuid",
"shopPOId": "uuid",
"mode": "CreditDeduction",
"totalCreditAmount": 1700.00,
"orderTotalAmountAfter": 13300.00,
"isActive": true,
"createdAt": "2026-09-17T11:00:00",
"items": [
{
"id": "uuid",
"itemId": "uuid",
"sku": "MILO400",
"itemName": "Milo 400g",
"quantity": 2.0,
"reasonCode": "Damaged",
"unitPrice": 850.00
}
]
}
]
```
### `GET /api/returns/{id}`
**Response:** `SaleReturnResponse` or `404`.
### `GET /api/shop-pos/{id}/returns`
List all returns for a specific ShopPO.
**Response:** `SaleReturnResponse[]`
### `POST /api/shop-pos/{id}/returns`
> **501 Not Implemented** — return flow under redesign.
**Request body (for future reference):**
```json
{
"mode": "CreditDeduction",
"items": [
{ "itemId": "uuid", "quantity": 2.0, "reasonCode": "Damaged" }
],
"createdByUserId": "uuid"
}
```
| `mode` | Effect |
|--------|--------|
| `CreditDeduction` | `totalCreditAmount` is netted off the ShopPO's `TotalAmount` |
| `ItemExchange` | Items are swapped; no monetary credit applied |
### `PATCH /api/returns/{id}/reasons`
Update the reason code on individual return line items. Only reason codes can be edited post-creation.
**Request body:**
```json
{
"items": [
{ "itemId": "uuid", "reasonCode": "Expired" }
]
}
```
**Response:** `SaleReturnResponse`. `409` if return is no longer active.
---
## Schemes
Schemes are promotional rules applied to ShopPOs (formerly Orders).
### Scheme Rule Types
| Type | Description |
|------|-------------|
| `SlabDiscount` | Tiered % discount based on order amount. Config: `SlabDiscountConfig` |
| `FlatPercent` | Flat % discount on order total. Config: `FlatPercentConfig` |
| `BuyXGetY` | Buy N of trigger item → get free payout item |
| `FreeBundle` | Buy a bundle of items → get a free payout item |
### `GET /api/schemes`
List all schemes (paginated).
**Response:** `PagedResponse<SchemeResponse>`
```json
{
"items": [
{
"id": "uuid",
"name": "Q3 Volume Boost",
"ruleType": "SlabDiscount",
"ruleConfig": "{\"Tiers\":[{\"MinOrderAmount\":10000,\"DiscountPercent\":5}]}",
"triggerItemId": null,
"triggerItemName": null,
"triggerQty": null,
"freeItemId": null,
"freeItemName": null,
"freeQty": null,
"bundleComponents": [],
"regionId": "uuid | null",
"regionName": "Western Province | null",
"eligibleSegment": null,
"dateRangeFrom": "2026-07-01T00:00:00",
"dateRangeTo": "2026-09-30T00:00:00",
"agentLimitExempt": false,
"baselineExpectedSales": 500000.00,
"isActive": true,
"createdAt": "2026-06-15T09:00:00"
}
]
}
```
### `GET /api/schemes/{id}`
**Response:** `SchemeResponse` or `404`.
### `POST /api/schemes`
Create a scheme. For discount-only schemes (`SlabDiscount`, `FlatPercent`), free-goods fields can be omitted. For `BuyXGetY` / `FreeBundle`, you can supply the free-goods config inline here or set it later via `PUT .../free-goods-rule`.
**Request body:**
```json
{
"name": "Q3 Volume Boost",
"ruleType": "SlabDiscount",
"ruleConfig": "{\"Tiers\":[{\"MinOrderAmount\":10000,\"DiscountPercent\":5}]}",
"regionId": null,
"eligibleSegment": null,
"dateRangeFrom": "2026-07-01T00:00:00",
"dateRangeTo": "2026-09-30T00:00:00",
"agentLimitExempt": false,
"baselineExpectedSales": 500000.00
}
```
#### `ruleConfig` shapes
**SlabDiscount:**
```json
{ "Tiers": [ { "MinOrderAmount": 10000, "DiscountPercent": 5 }, { "MinOrderAmount": 20000, "DiscountPercent": 8 } ] }
```
**FlatPercent:**
```json
{ "DiscountPercent": 3.5 }
```
**BuyXGetY / FreeBundle:** `ruleConfig` is `null` — the payout is set via `PUT .../free-goods-rule`.
### `PUT /api/schemes/{id}`
Update scheme metadata. Cannot change `ruleType`.
### `DELETE /api/schemes/{id}`
Soft-delete (sets `IsActive = false`).
### `PUT /api/schemes/{id}/free-goods-rule`
Set the free-goods payout for `BuyXGetY` or `FreeBundle` schemes.
**Request body:**
```json
{
"triggerItemId": "uuid | null",
"triggerQty": 6,
"freeItemId": "uuid",
"freeQty": 1.0
}
```
- `BuyXGetY`: `triggerItemId` + `triggerQty` required (what store must buy).
- `FreeBundle`: `triggerItemId` / `triggerQty` ignored — trigger is the bundle components.
### `PUT /api/schemes/{id}/bundle-components/{itemId}`
Add or update a component in a `FreeBundle` scheme.
**Request body:**
```json
{ "qty": 4 }
```
### `DELETE /api/schemes/{id}/bundle-components/{itemId}`
Remove a bundle component.
### `GET /api/shop-pos/{shopPOId}/scheme-eligibility`
Check which active schemes the ShopPO qualifies for.
**Response:** `SchemeEligibilityResponse[]`
```json
[
{
"schemeId": "uuid",
"schemeName": "Q3 Volume Boost",
"ruleType": "SlabDiscount",
"eligible": true,
"reason": null,
"discountAmount": 750.00,
"freeItemId": null,
"freeQty": null
}
]
```
### `POST /api/shop-pos/{shopPOId}/apply-scheme`
Apply a scheme to the ShopPO (create a redemption record and apply discount/free goods).
**Request body:**
```json
{ "schemeId": "uuid" }
```
**Response:** `SchemeRedemptionResponse`
```json
{
"id": "uuid",
"schemeId": "uuid",
"shopPOId": "uuid",
"discountAmount": 750.00,
"freeItemId": null,
"freeItemName": null,
"freeQty": null,
"freeItemCostValue": null,
"redeemedAt": "2026-09-17T09:30:00"
}
```
### `GET /api/schemes/{id}/spend`
**Response:** `SchemeSpendResponse`
```json
{
"schemeId": "uuid",
"totalDiscountSpend": 45000.00,
"totalFreeGoodsCostSpend": 12000.00,
"totalSpend": 57000.00,
"redemptionCount": 38
}
```
### `GET /api/schemes/{id}/roi`
**Response:** `SchemeROIResponse`
```json
{
"schemeId": "uuid",
"baselineExpectedSales": 500000.00,
"actualSales": 620000.00,
"incrementalSales": 120000.00,
"totalSpend": 57000.00,
"roi": 1.105
}
```
---
## System Config
Key-value store for system-wide configuration. All keys and values are strings. Only known keys have typed endpoints with validation.
### `GET /api/sys-config`
Return all config entries.
**Response:** `SysConfigResponse[]`
```json
[
{
"key": "InvoiceCreationMode",
"value": "ModeB",
"updatedAt": "2026-09-17T00:00:00",
"updatedBy": null
}
]
```
### `GET /api/sys-config/invoice-creation-mode`
**Response:**
```json
{ "mode": "ModeB" }
```
### `PUT /api/sys-config/invoice-creation-mode`
**Request body:**
```json
{ "mode": "ModeA" }
```
| Value | Behaviour |
|-------|-----------|
| `ModeA` | Invoice is auto-created when a ShopPO is confirmed |
| `ModeB` | Invoice must be created as a separate explicit step (default) |
**Response:** `SysConfigResponse`. `400` if value is not a valid `InvoiceCreationMode`.
### `GET /api/sys-config/{key}`
Generic getter by key string. `404` if key not set.
### `PUT /api/sys-config/{key}`
Generic raw string setter. Requires admin-level auth.
**Request body:**
```json
{ "value": "SomeValue" }
```
**Response:** `SysConfigResponse`. `400` if value fails validation for a known key.
---
## Key Relationships & Flows
### Core Entity Chain
```
Store ──< ShopPO ──< ShopPOItem
│
└──► Invoice ──< Payment
│
└──► SaleReturn ──< SaleReturnItem
│
└──► SchemeRedemption
Ride (DeliveryRun) ──► LoadingSheet ──< LoadingSheetItem
└──< DeliveryTask ──► ShopPO
```
### ShopPO → Invoice Dependency
- `ShopPO.invoiceId` is `null` until an invoice is created.
- In **ModeA**, the invoice is created automatically when the ShopPO is confirmed.
- In **ModeB**, the invoice must be created via a dedicated endpoint (not yet exposed — planned).
- An invoice can only exist for a confirmed ShopPO.
### Payment → Invoice Dependency
- Payments are always attached to an **Invoice**, never directly to a ShopPO.
- `Invoice.paymentStatus` is computed:
- `Pending` — no payments
- `PartiallyPaid` — payments collected but total < invoice amount
- `Paid` — fully settled
### Loading Sheet → Stock
- Confirming a loading sheet deducts each item from its **source warehouse** (`fK_WarehouseId` per line) and credits the **vehicle** stock location.
- Each loading sheet line can optionally link to a `fK_ShopPOItemId` for traceability.
### Delivery Task → ShopPO Fulfillment
- Confirming a delivery task updates `ShopPOItem.quantityFulfilled` and transitions the ShopPO status:
- All items fully fulfilled → `Fulfilled`
- Some items partially fulfilled → `PartiallyFulfilled`
### Returns → ShopPO
- Returns always reference a `shopPOId`.
- `CreditDeduction` mode: `SaleReturn.totalCreditAmount` is netted off `ShopPO.totalAmount` immediately.
- `ItemExchange` mode: no monetary impact; items are swapped in stock.
- Only the `reasonCode` on each return line can be edited after creation.
---
## What Is Not Yet Implemented
| Feature | Status | Notes |
|---------|--------|-------|
| ShopPO confirm endpoint | Pending | `PUT /api/shop-pos/{id}/confirm` — triggers InvoiceCreationMode check |
| Invoice create (ModeB) | Pending | `POST /api/invoices` with `{ shopPOId }` |
| Invoice print | Pending | `POST /api/invoices/{id}/print` |
| Invoice finalize | Pending | `POST /api/invoices/{id}/finalize` |
| Delivery task auto-generation | Deferred | Route-based dispatch not yet implemented |
| Returns create | Redesign | `POST /api/shop-pos/{id}/returns` returns `501` |
| Permission seeding | Pending | `loading.populate`, `loading.confirm` scopes |
@@ -51,7 +51,7 @@ export function DeliveryTasksClient() {
const fetchRides = useCallback(async () => { const fetchRides = useCallback(async () => {
try { try {
const res = await ridesApi.list({ page: 1, pageSize: 200, rideType: 'Delivery' }) const res = await ridesApi.list({ page: 1, pageSize: 200 })
const items = getPaginatedItems(res) const items = getPaginatedItems(res)
setRides(items) setRides(items)
setSelectedRideId((current) => current || items[0]?.id || '') setSelectedRideId((current) => current || items[0]?.id || '')
@@ -188,7 +188,7 @@ export function DeliveryTasksClient() {
<SelectItem value="__none__" disabled={rides.length > 0}>Select a ride...</SelectItem> <SelectItem value="__none__" disabled={rides.length > 0}>Select a ride...</SelectItem>
{rides.map((ride) => ( {rides.map((ride) => (
<SelectItem key={ride.id} value={ride.id}> <SelectItem key={ride.id} value={ride.id}>
{ride.routeName} - {ride.vehicleNumberPlate} {ride.vehicleNumberPlate} — {ride.driverFullName}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -0,0 +1,113 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { loadingSheets as loadingSheetsApi, rides as ridesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RideResponse } from '@/types/api'
const schema = z.object({
fK_RideId: z.string().min(1, 'Ride is required'),
})
type FormValues = z.infer<typeof schema>
interface LoadingSheetCreateDialogProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
export function LoadingSheetCreateDialog({ open, onClose, onSuccess }: LoadingSheetCreateDialogProps) {
const [ridesList, setRidesList] = useState<RideResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { fK_RideId: '' },
})
useEffect(() => {
if (!open) return
form.reset({ fK_RideId: '' })
ridesApi.list({ status: 'Open', pageSize: 200 })
.then((res) => setRidesList(getPaginatedItems(res)))
.catch((err) => toast.add({ title: 'Failed to load rides', description: getApiError(err), type: 'error' }))
}, [open])
async function onSubmit(values: FormValues) {
try {
await loadingSheetsApi.create(values)
toast.add({ title: 'Loading sheet created', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to create loading sheet', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>New Loading Sheet</DialogTitle>
<DialogDescription>
Create a loading sheet for an open ride. Only one planned sheet per ride is allowed.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="fK_RideId"
label="Ride"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select open ride..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select open ride...</SelectItem>
{ridesList.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.vehicleNumberPlate} — {r.driverFullName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating…' : 'Create Sheet'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -11,11 +11,8 @@ import { Separator } from '@/components/ui/separator'
import type { LoadingSheetResponse, LoadingSheetStatus } from '@/types/api' import type { LoadingSheetResponse, LoadingSheetStatus } from '@/types/api'
const LS_STATUS_VARIANT: Record<LoadingSheetStatus, 'default' | 'secondary' | 'destructive'> = { const LS_STATUS_VARIANT: Record<LoadingSheetStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary', Planned: 'secondary',
DispatchConfirmed: 'default', Confirmed: 'default',
ReceiptConfirmed: 'default',
DiscrepancyFlagged: 'destructive',
Resolved: 'secondary',
} }
const fmtDateTime = (v: string | null) => const fmtDateTime = (v: string | null) =>
@@ -25,6 +22,8 @@ const fmtDateTime = (v: string | null) =>
}) })
: '—' : '—'
const truncId = (id: string) => id.substring(0, 8)
interface LoadingSheetViewSheetProps { interface LoadingSheetViewSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
@@ -51,13 +50,12 @@ export function LoadingSheetViewSheet({ open, onClose, sheet }: LoadingSheetView
</DialogHeader> </DialogHeader>
<div className="flex flex-col"> <div className="flex flex-col">
<Row label="Sheet ID" value={<span className="font-mono">{sheet.id.substring(0, 8)}</span>} /> <Row label="Sheet ID" value={<span className="font-mono">{truncId(sheet.id)}</span>} />
<Row label="Warehouse" value={sheet.warehouseName} /> <Row label="Ride ID" value={<span className="font-mono">{truncId(sheet.fK_RideId)}</span>} />
<Row label="Status" value={<Badge variant={LS_STATUS_VARIANT[sheet.status]}>{sheet.status}</Badge>} /> <Row label="Status" value={<Badge variant={LS_STATUS_VARIANT[sheet.status]}>{sheet.status}</Badge>} />
<Row label="Dispatch Confirmed By" value={sheet.dispatchConfirmedBy ?? '—'} /> <Row label="Populated At" value={fmtDateTime(sheet.populatedAt)} />
<Row label="Dispatch Confirmed At" value={fmtDateTime(sheet.dispatchConfirmedAt)} /> <Row label="Confirmed At" value={fmtDateTime(sheet.confirmedAt)} />
<Row label="Receipt Confirmed By" value={sheet.receiptConfirmedBy ?? '—'} /> <Row label="Source POs" value={sheet.orders.length > 0 ? sheet.orders.map(o => truncId(o.shopPOId)).join(', ') : '—'} />
<Row label="Receipt Confirmed At" value={fmtDateTime(sheet.receiptConfirmedAt)} />
<Row label="Created At" value={fmtDateTime(sheet.createdAt)} /> <Row label="Created At" value={fmtDateTime(sheet.createdAt)} />
</div> </div>
@@ -70,15 +68,14 @@ export function LoadingSheetViewSheet({ open, onClose, sheet }: LoadingSheetView
<thead className="bg-muted/50"> <thead className="bg-muted/50">
<tr> <tr>
<th className="text-left font-medium px-3 py-2">Item</th> <th className="text-left font-medium px-3 py-2">Item</th>
<th className="text-right font-medium px-3 py-2">Dispatched</th> <th className="text-left font-medium px-3 py-2">Warehouse</th>
<th className="text-right font-medium px-3 py-2">Received</th> <th className="text-right font-medium px-3 py-2">Qty</th>
<th className="text-right font-medium px-3 py-2">Resolved</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sheet.items.length === 0 ? ( {sheet.items.length === 0 ? (
<tr> <tr>
<td colSpan={4} className="text-center text-muted-foreground px-3 py-4"> <td colSpan={3} className="text-center text-muted-foreground px-3 py-4">
No items on this sheet. No items on this sheet.
</td> </td>
</tr> </tr>
@@ -86,9 +83,8 @@ export function LoadingSheetViewSheet({ open, onClose, sheet }: LoadingSheetView
sheet.items.map((item) => ( sheet.items.map((item) => (
<tr key={item.id} className="border-t"> <tr key={item.id} className="border-t">
<td className="px-3 py-2">{item.itemName}</td> <td className="px-3 py-2">{item.itemName}</td>
<td className="px-3 py-2 text-muted-foreground">{item.warehouseName}</td>
<td className="text-right px-3 py-2">{item.quantityDispatched}</td> <td className="text-right px-3 py-2">{item.quantityDispatched}</td>
<td className="text-right px-3 py-2">{item.quantityReceived}</td>
<td className="text-right px-3 py-2">{item.quantityResolved}</td>
</tr> </tr>
)) ))
)} )}
@@ -1,14 +1,19 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table' import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table' import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { loadingSheets as loadingSheetsApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { loadingSheets as loadingSheetsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { LoadingSheetResponse, LoadingSheetStatus } from '@/types/api' import type { LoadingSheetResponse, LoadingSheetStatus } from '@/types/api'
import { LoadingSheetViewSheet } from './loading-sheet-view-sheet' import { LoadingSheetViewSheet } from './loading-sheet-view-sheet'
import { LoadingSheetCreateDialog } from './loading-sheet-create-dialog'
const fmtDate = (v: string) => const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -16,11 +21,8 @@ const fmtDate = (v: string) =>
const truncId = (id: string) => id.substring(0, 8) const truncId = (id: string) => id.substring(0, 8)
const LS_STATUS_VARIANT: Record<LoadingSheetStatus, 'default' | 'secondary' | 'destructive'> = { const LS_STATUS_VARIANT: Record<LoadingSheetStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary', Planned: 'secondary',
DispatchConfirmed: 'default', Confirmed: 'default',
ReceiptConfirmed: 'default',
DiscrepancyFlagged: 'destructive',
Resolved: 'secondary',
} }
export function LoadingSheetsClient() { export function LoadingSheetsClient() {
@@ -30,6 +32,7 @@ export function LoadingSheetsClient() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [createOpen, setCreateOpen] = useState(false)
const [viewOpen, setViewOpen] = useState(false) const [viewOpen, setViewOpen] = useState(false)
const [selectedSheet, setSelectedSheet] = useState<LoadingSheetResponse | null>(null) const [selectedSheet, setSelectedSheet] = useState<LoadingSheetResponse | null>(null)
@@ -55,23 +58,13 @@ export function LoadingSheetsClient() {
setViewOpen(true) setViewOpen(true)
} }
async function handleConfirmDispatch(row: LoadingSheetResponse) { async function handleConfirm(row: LoadingSheetResponse) {
try { try {
await loadingSheetsApi.confirmDispatch(row.id) await loadingSheetsApi.confirm(row.id)
toast.add({ title: 'Dispatch confirmed', type: 'success' }) toast.add({ title: 'Loading sheet confirmed — stock committed to vehicle', type: 'success' })
fetchData(page, pageSize) fetchData(page, pageSize)
} catch (err) { } catch (err) {
toast.add({ title: 'Failed to confirm dispatch', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to confirm loading sheet', description: getApiError(err), type: 'error' })
}
}
async function handleConfirmReceipt(row: LoadingSheetResponse) {
try {
await loadingSheetsApi.confirmReceipt(row.id, [])
toast.add({ title: 'Receipt confirmed', type: 'success' })
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to confirm receipt', description: getApiError(err), type: 'error' })
} }
} }
@@ -92,33 +85,45 @@ export function LoadingSheetsClient() {
), ),
}, },
{ {
key: 'warehouseName', key: 'rideId',
title: 'Warehouse', title: 'Ride',
fields: [{ key: 'warehouseName', primary: true }], fields: ['fK_RideId'],
minWidth: 180, width: 140,
cellRender: (row) => (
<span className="font-mono text-sm text-muted-foreground">{truncId(row.fK_RideId)}</span>
),
}, },
{ {
key: 'status', key: 'status',
title: 'Status', title: 'Status',
fields: ['status'], fields: ['status'],
width: 180, width: 140,
cellRender: (row) => ( cellRender: (row) => (
<Badge variant={LS_STATUS_VARIANT[row.status]}>{row.status}</Badge> <Badge variant={LS_STATUS_VARIANT[row.status]}>{row.status}</Badge>
), ),
}, },
{
key: 'orders',
title: 'POs',
fields: ['orders'],
width: 80,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">{row.orders.length}</span>
),
},
{ {
key: 'itemCount', key: 'itemCount',
title: 'Items', title: 'Items',
fields: ['items'], fields: ['items'],
width: 120, width: 80,
cellRender: (row) => ( cellRender: (row) => (
<span className="text-sm text-muted-foreground">{row.items.length} items</span> <span className="text-sm text-muted-foreground">{row.items.length}</span>
), ),
}, },
{ {
key: 'createdAt', key: 'populatedAt',
title: 'Created', title: 'Populated',
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }], fields: [{ key: 'populatedAt', transform: (v) => fmtDate(v as string) }],
width: 140, width: 140,
}, },
], ],
@@ -131,15 +136,9 @@ export function LoadingSheetsClient() {
}, },
{ {
action: 'confirm', action: 'confirm',
label: 'Confirm dispatch', label: 'Confirm & commit stock',
onClick: handleConfirmDispatch, onClick: handleConfirm,
visible: (target) => target.status === 'Pending', visible: (target) => target.status === 'Planned',
},
{
action: 'confirm',
label: 'Confirm receipt',
onClick: handleConfirmReceipt,
visible: (target) => target.status === 'DispatchConfirmed',
}, },
], ],
}, },
@@ -151,9 +150,13 @@ export function LoadingSheetsClient() {
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">Loading Sheets</h1> <h1 className="text-2xl font-semibold text-foreground">Loading Sheets</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
Track goods loaded onto a vehicle before dispatch Items loaded onto a vehicle before a delivery run
</p> </p>
</div> </div>
<Button onClick={() => setCreateOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} />
New Sheet
</Button>
</div> </div>
<DataTable <DataTable
@@ -167,6 +170,12 @@ export function LoadingSheetsClient() {
}} }}
/> />
<LoadingSheetCreateDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
<LoadingSheetViewSheet <LoadingSheetViewSheet
open={viewOpen} open={viewOpen}
onClose={() => setViewOpen(false)} onClose={() => setViewOpen(false)}
@@ -57,12 +57,12 @@ export function RideCancelDialog({ open, onClose, onSuccess, ride }: RideCancelD
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
try { try {
await ridesApi.cancelRequests.raise(ride!.id, values) await ridesApi.cancel(ride!.id, values)
toast.add({ title: 'Cancel request raised', type: 'success' }) toast.add({ title: 'Ride cancelled', type: 'success' })
onSuccess() onSuccess()
onClose() onClose()
} catch (err) { } catch (err) {
toast.add({ title: 'Failed to raise cancel request', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to cancel ride', description: getApiError(err), type: 'error' })
} }
} }
@@ -72,7 +72,7 @@ export function RideCancelDialog({ open, onClose, onSuccess, ride }: RideCancelD
<DialogHeader> <DialogHeader>
<DialogTitle>Cancel Ride</DialogTitle> <DialogTitle>Cancel Ride</DialogTitle>
<DialogDescription> <DialogDescription>
Raises a cancel request for this ride, subject to supervisor approval. Cancelling this ride is immediate and cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -122,7 +122,7 @@ export function RideCancelDialog({ open, onClose, onSuccess, ride }: RideCancelD
Back Back
</Button> </Button>
<Button type="submit" variant="destructive" disabled={form.formState.isSubmitting}> <Button type="submit" variant="destructive" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Submitting…' : 'Raise Cancel Request'} {form.formState.isSubmitting ? 'Cancelling…' : 'Cancel Ride'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -72,7 +72,7 @@ export function RideEditDriverSheet({ open, onClose, onSuccess, ride }: RideEdit
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Ride Driver</DialogTitle> <DialogTitle>Edit Ride Driver</DialogTitle>
<DialogDescription> <DialogDescription>
Reassign the driver for this ride. Only available while the ride is in Draft status. Reassign the driver for this ride. Only available while the ride is Open.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -26,19 +26,14 @@ import {
rides as ridesApi, rides as ridesApi,
vehicles as vehiclesApi, vehicles as vehiclesApi,
drivers as driversApi, drivers as driversApi,
routes as routesApi,
warehouses as warehousesApi,
getApiError, getApiError,
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } from '@/lib/api/index'
import type { VehicleResponse, DriverProfileResponse, RouteResponse, WarehouseResponse } from '@/types/api' import type { VehicleResponse, DriverProfileResponse } from '@/types/api'
const schema = z.object({ const schema = z.object({
rideType: z.enum(['Delivery', 'Sale']),
vehicleId: z.string().min(1, 'Vehicle is required'), vehicleId: z.string().min(1, 'Vehicle is required'),
driverProfileId: z.string().min(1, 'Driver is required'), driverProfileId: z.string().min(1, 'Driver is required'),
routeId: z.string().min(1, 'Route is required'),
sourceWarehouseId: z.string().min(1, 'Warehouse is required'),
meterstart: z.number().min(0, 'Meter start must be 0 or more'), meterstart: z.number().min(0, 'Meter start must be 0 or more'),
}) })
@@ -50,21 +45,15 @@ interface RideSheetProps {
onSuccess: () => void onSuccess: () => void
} }
export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) { export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
const [vehiclesList, setVehiclesList] = useState<VehicleResponse[]>([]) const [vehiclesList, setVehiclesList] = useState<VehicleResponse[]>([])
const [driversList, setDriversList] = useState<DriverProfileResponse[]>([]) const [driversList, setDriversList] = useState<DriverProfileResponse[]>([])
const [routesList, setRoutesList] = useState<RouteResponse[]>([])
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
rideType: 'Delivery',
vehicleId: '', vehicleId: '',
driverProfileId: '', driverProfileId: '',
routeId: '',
sourceWarehouseId: '',
meterstart: 0, meterstart: 0,
}, },
}) })
@@ -73,26 +62,18 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
if (!open) return if (!open) return
form.reset({ form.reset({
rideType: 'Delivery',
vehicleId: '', vehicleId: '',
driverProfileId: '', driverProfileId: '',
routeId: '',
sourceWarehouseId: '',
meterstart: 0, meterstart: 0,
}) })
// available=true tells backend to exclude vehicles already on an open ride
Promise.all([ Promise.all([
vehiclesApi.list({ available: true, isActive: true, pageSize: 200 }), vehiclesApi.list({ available: true, isActive: true, pageSize: 200 }),
driversApi.list({ pageSize: 200 }), driversApi.list({ pageSize: 200 }),
routesApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
]) ])
.then(([v, d, r, w]) => { .then(([v, d]) => {
setVehiclesList(getPaginatedItems(v)) setVehiclesList(getPaginatedItems(v))
setDriversList(getPaginatedItems(d)) setDriversList(getPaginatedItems(d))
setRoutesList(getPaginatedItems(r))
setWarehousesList(getPaginatedItems(w))
}) })
.catch((err) => { .catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
@@ -115,30 +96,11 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
<DialogContent className="overflow-y-auto sm:max-w-lg"> <DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Create Ride</DialogTitle> <DialogTitle>Create Ride</DialogTitle>
<DialogDescription>Start a new delivery or sales run.</DialogDescription> <DialogDescription>Start a new delivery run for a driver and vehicle.</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup> <FieldGroup>
<FormField
control={form.control}
name="rideType"
label="Ride Type"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select ride type..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select ride type...</SelectItem>
<SelectItem value="Delivery">Delivery</SelectItem>
<SelectItem value="Sale">Sale</SelectItem>
</SelectContent>
</Select>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="vehicleId" name="vehicleId"
@@ -183,50 +145,6 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
)} )}
/> />
<FormField
control={form.control}
name="routeId"
label="Route"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select route..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select route...</SelectItem>
{routesList.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.routeName} ({r.startPoint} → {r.endPoint})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="sourceWarehouseId"
label="Source Warehouse"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select warehouse...</SelectItem>
{warehousesList.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.name} — {w.location}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="meterstart" name="meterstart"
@@ -7,22 +7,14 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import type { RideResponse, RideStatus, RideType } from '@/types/api' import type { RideResponse, RideStatus } from '@/types/api'
const RIDE_STATUS_VARIANT: Record<RideStatus, 'default' | 'secondary' | 'destructive'> = { const RIDE_STATUS_VARIANT: Record<RideStatus, 'default' | 'secondary' | 'destructive'> = {
Draft: 'secondary', Open: 'default',
Open: 'secondary',
Active: 'default',
CancelRequested: 'destructive',
Cancelled: 'destructive', Cancelled: 'destructive',
Closed: 'secondary', Closed: 'secondary',
} }
const RIDE_TYPE_VARIANT: Record<RideType, 'default' | 'secondary'> = {
Delivery: 'default',
Sale: 'secondary',
}
const fmtDateTime = (v: string | null) => const fmtDateTime = (v: string | null) =>
v v
? new Date(v).toLocaleString('en-GB', { ? new Date(v).toLocaleString('en-GB', {
@@ -58,14 +50,10 @@ export function RideViewSheet({ open, onClose, ride }: RideViewSheetProps) {
<div className="flex flex-col"> <div className="flex flex-col">
<Row label="Vehicle" value={ride.vehicleNumberPlate} /> <Row label="Vehicle" value={ride.vehicleNumberPlate} />
<Row label="Driver" value={ride.driverFullName} /> <Row label="Driver" value={ride.driverFullName} />
<Row label="Route" value={ride.routeName} />
<Row label="Source Warehouse" value={ride.sourceWarehouseName} />
<Row label="Type" value={<Badge variant={RIDE_TYPE_VARIANT[ride.rideType]}>{ride.rideType}</Badge>} />
<Row label="Status" value={<Badge variant={RIDE_STATUS_VARIANT[ride.status]}>{ride.status}</Badge>} /> <Row label="Status" value={<Badge variant={RIDE_STATUS_VARIANT[ride.status]}>{ride.status}</Badge>} />
<Row label="Meter Start" value={ride.meterstart} /> <Row label="Meter Start" value={`${ride.meterstart} km`} />
<Row label="Meter End" value={ride.meterend || '—'} /> <Row label="Meter End" value={ride.meterend ? `${ride.meterend} km` : '—'} />
<Row label="Started At" value={fmtDateTime(ride.startedAt)} /> <Row label="Started At" value={fmtDateTime(ride.startedAt)} />
<Row label="Activated At" value={fmtDateTime(ride.activatedAt)} />
<Row label="Closed At" value={fmtDateTime(ride.closedAt)} /> <Row label="Closed At" value={fmtDateTime(ride.closedAt)} />
<Row label="Created At" value={fmtDateTime(ride.createdAt)} /> <Row label="Created At" value={fmtDateTime(ride.createdAt)} />
</div> </div>
@@ -10,7 +10,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { rides as ridesApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { rides as ridesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { RideResponse, RideStatus, RideType } from '@/types/api' import type { RideResponse, RideStatus } from '@/types/api'
import { RideSheet } from './ride-sheet' import { RideSheet } from './ride-sheet'
import { RideViewSheet } from './ride-view-sheet' import { RideViewSheet } from './ride-view-sheet'
@@ -22,19 +22,11 @@ const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const RIDE_STATUS_VARIANT: Record<RideStatus, 'default' | 'secondary' | 'destructive'> = { const RIDE_STATUS_VARIANT: Record<RideStatus, 'default' | 'secondary' | 'destructive'> = {
Draft: 'secondary', Open: 'default',
Open: 'secondary',
Active: 'default',
CancelRequested: 'destructive',
Cancelled: 'destructive', Cancelled: 'destructive',
Closed: 'secondary', Closed: 'secondary',
} }
const RIDE_TYPE_VARIANT: Record<RideType, 'default' | 'secondary'> = {
Delivery: 'default',
Sale: 'secondary',
}
export function RidesClient() { export function RidesClient() {
const [data, setData] = useState<RideResponse[]>([]) const [data, setData] = useState<RideResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -81,16 +73,6 @@ export function RidesClient() {
setCancelOpen(true) setCancelOpen(true)
} }
async function handleActivate(row: RideResponse) {
try {
await ridesApi.activate(row.id)
toast.add({ title: 'Ride activated', type: 'success' })
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to activate ride', description: getApiError(err), type: 'error' })
}
}
function handleClose(row: RideResponse) { function handleClose(row: RideResponse) {
setSelectedRide(row) setSelectedRide(row)
setCloseOpen(true) setCloseOpen(true)
@@ -115,30 +97,29 @@ export function RidesClient() {
</div> </div>
), ),
}, },
{
key: 'routeName',
title: 'Route',
fields: [{ key: 'routeName', primary: true }],
minWidth: 160,
},
{
key: 'rideType',
title: 'Type',
fields: ['rideType'],
width: 120,
cellRender: (row) => (
<Badge variant={RIDE_TYPE_VARIANT[row.rideType]}>{row.rideType}</Badge>
),
},
{ {
key: 'status', key: 'status',
title: 'Status', title: 'Status',
fields: ['status'], fields: ['status'],
width: 160, width: 140,
cellRender: (row) => ( cellRender: (row) => (
<Badge variant={RIDE_STATUS_VARIANT[row.status]}>{row.status}</Badge> <Badge variant={RIDE_STATUS_VARIANT[row.status]}>{row.status}</Badge>
), ),
}, },
{
key: 'meter',
title: 'Meter',
fields: ['meterstart', 'meterend'],
width: 160,
cellRender: (row) => (
<div>
<p className="text-sm">{row.meterstart} km</p>
{row.meterend ? (
<p className="text-xs text-muted-foreground">→ {row.meterend} km</p>
) : null}
</div>
),
},
{ {
key: 'createdAt', key: 'createdAt',
title: 'Created', title: 'Created',
@@ -157,25 +138,19 @@ export function RidesClient() {
action: 'edit', action: 'edit',
label: 'Edit driver', label: 'Edit driver',
onClick: handleEditDriver, onClick: handleEditDriver,
visible: (target) => target.status === 'Draft',
},
{
action: 'activate',
label: 'Activate ride',
onClick: handleActivate,
visible: (target) => target.status === 'Open', visible: (target) => target.status === 'Open',
}, },
{ {
action: 'close', action: 'close',
label: 'Close ride', label: 'Close ride',
onClick: handleClose, onClick: handleClose,
visible: (target) => target.status === 'Active', visible: (target) => target.status === 'Open',
}, },
{ {
action: 'cancel', action: 'cancel',
label: 'Cancel ride', label: 'Cancel ride',
onClick: handleCancel, onClick: handleCancel,
visible: (target) => target.status === 'Active', visible: (target) => target.status === 'Open',
}, },
], ],
}, },
@@ -192,7 +167,7 @@ export function RidesClient() {
</div> </div>
<Button onClick={() => setSheetOpen(true)}> <Button onClick={() => setSheetOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} /> <HugeiconsIcon icon={Add01Icon} size={16} />
Add Ride New Ride
</Button> </Button>
</div> </div>
@@ -27,14 +27,13 @@ import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons' import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
import { import {
saleReturns as saleReturnsApi, saleReturns as saleReturnsApi,
orders as ordersApi, shopPos as shopPosApi,
stores as storesApi,
items as itemsApi, items as itemsApi,
users as usersApi, users as usersApi,
getApiError, getApiError,
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } from '@/lib/api/index'
import type { OrderResponse, ItemSummaryResponse, UserResponse, ReturnReasonCode } from '@/types/api' import type { ShopPOResponse, ItemSummaryResponse, UserResponse, ReturnReasonCode } from '@/types/api'
const REASON_CODES: ReturnReasonCode[] = ['Expired', 'Damaged', 'OrderError', 'Other'] const REASON_CODES: ReturnReasonCode[] = ['Expired', 'Damaged', 'OrderError', 'Other']
@@ -45,7 +44,7 @@ const returnItemSchema = z.object({
}) })
const schema = z.object({ const schema = z.object({
orderId: z.string().min(1, 'Order is required'), shopPOId: z.string().min(1, 'Shop PO is required'),
mode: z.enum(['CreditDeduction', 'ItemExchange']), mode: z.enum(['CreditDeduction', 'ItemExchange']),
createdByUserId: z.string().min(1, 'Created by is required'), createdByUserId: z.string().min(1, 'Created by is required'),
items: z.array(returnItemSchema).min(1, 'At least one item is required'), items: z.array(returnItemSchema).min(1, 'At least one item is required'),
@@ -64,58 +63,55 @@ function defaultItem() {
} }
export function OrderReturnDialog({ open, onClose, onSuccess }: OrderReturnDialogProps) { export function OrderReturnDialog({ open, onClose, onSuccess }: OrderReturnDialogProps) {
const [ordersList, setOrdersList] = useState<OrderResponse[]>([]) const [shopPOList, setShopPOList] = useState<ShopPOResponse[]>([])
const [storeNameById, setStoreNameById] = useState<Map<string, string>>(new Map())
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([]) const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([]) const [usersList, setUsersList] = useState<UserResponse[]>([])
const [selectedOrder, setSelectedOrder] = useState<OrderResponse | null>(null) const [selectedPO, setSelectedPO] = useState<ShopPOResponse | null>(null)
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { orderId: '', mode: 'CreditDeduction', createdByUserId: '', items: [defaultItem()] }, defaultValues: { shopPOId: '', mode: 'CreditDeduction', createdByUserId: '', items: [defaultItem()] },
}) })
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' }) const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
const itemsById = useMemo(() => new Map(itemsList.map((i) => [i.id, i])), [itemsList]) const itemsById = useMemo(() => new Map(itemsList.map((i) => [i.id, i])), [itemsList])
const eligibleOrders = useMemo( const eligiblePOs = useMemo(
() => ordersList.filter((o) => o.status !== 'Cancelled' && o.status !== 'Fulfilled'), () => shopPOList.filter((o) => o.status !== 'Cancelled' && o.confirmedAt),
[ordersList] [shopPOList]
) )
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
form.reset({ orderId: '', mode: 'CreditDeduction', createdByUserId: '', items: [defaultItem()] }) form.reset({ shopPOId: '', mode: 'CreditDeduction', createdByUserId: '', items: [defaultItem()] })
setSelectedOrder(null) setSelectedPO(null)
Promise.all([ Promise.all([
ordersApi.list(), shopPosApi.list({ pageSize: 200 }),
storesApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }), usersApi.list({ pageSize: 200 }),
]) ])
.then(([orderList, storeList, itemsRes, usersRes]) => { .then(([posRes, itemsRes, usersRes]) => {
setOrdersList(orderList) setShopPOList(getPaginatedItems(posRes))
setStoreNameById(new Map(getPaginatedItems(storeList).map((s) => [s.id, s.storeName])))
setItemsList(getPaginatedItems(itemsRes)) setItemsList(getPaginatedItems(itemsRes))
setUsersList(getPaginatedItems(usersRes)) setUsersList(getPaginatedItems(usersRes))
}) })
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })) .catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
}, [open]) }, [open])
function handleOrderChange(orderId: string | null) { function handlePOChange(shopPOId: string | null) {
form.setValue('orderId', orderId ?? '', { shouldValidate: true }) form.setValue('shopPOId', shopPOId ?? '', { shouldValidate: true })
const order = orderId ? ordersList.find((o) => o.id === orderId) ?? null : null const po = shopPOId ? shopPOList.find((o) => o.id === shopPOId) ?? null : null
setSelectedOrder(order) setSelectedPO(po)
} }
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
try { try {
await saleReturnsApi.create(values.orderId, { await saleReturnsApi.create(values.shopPOId, {
mode: values.mode, mode: values.mode,
items: values.items, items: values.items,
createdByUserId: values.createdByUserId, createdByUserId: values.createdByUserId,
}) })
toast.add({ title: 'Order return recorded', type: 'success' }) toast.add({ title: 'Return recorded', type: 'success' })
onSuccess() onSuccess()
onClose() onClose()
} catch (err) { } catch (err) {
@@ -123,14 +119,15 @@ export function OrderReturnDialog({ open, onClose, onSuccess }: OrderReturnDialo
} }
} }
const activeItems = selectedPO?.items.filter((i) => i.isActive) ?? []
return ( return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}> <Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl"> <DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Record Order Return</DialogTitle> <DialogTitle>Record Order Return</DialogTitle>
<DialogDescription> <DialogDescription>
Record items a store returned against an open order. Only items the store has previously Record items a store returned against a confirmed Shop PO.
ordered can be returned.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -138,19 +135,19 @@ export function OrderReturnDialog({ open, onClose, onSuccess }: OrderReturnDialo
<FieldGroup> <FieldGroup>
<FormField <FormField
control={form.control} control={form.control}
name="orderId" name="shopPOId"
label="Order" label="Shop PO"
required required
render={({ field }) => ( render={({ field }) => (
<Select value={field.value as string} onValueChange={handleOrderChange}> <Select value={field.value as string} onValueChange={handlePOChange}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select order..." /> <SelectValue placeholder="Select Shop PO..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="" disabled>Select order...</SelectItem> <SelectItem value="" disabled>Select Shop PO...</SelectItem>
{eligibleOrders.map((o) => ( {eligiblePOs.map((o) => (
<SelectItem key={o.id} value={o.id}> <SelectItem key={o.id} value={o.id}>
{storeNameById.get(o.storeId) ?? o.storeId.slice(0, 8)} — {o.id.slice(0, 8).toUpperCase()} ({o.status}) {o.storeName} — {o.id.slice(0, 8).toUpperCase()} ({o.status})
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -200,7 +197,7 @@ export function OrderReturnDialog({ open, onClose, onSuccess }: OrderReturnDialo
</div> </div>
</FieldGroup> </FieldGroup>
{selectedOrder && ( {selectedPO && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-medium text-foreground">Items to Return</p> <p className="text-sm font-medium text-foreground">Items to Return</p>
@@ -223,11 +220,11 @@ export function OrderReturnDialog({ open, onClose, onSuccess }: OrderReturnDialo
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="" disabled>Select item...</SelectItem> <SelectItem value="" disabled>Select item...</SelectItem>
{selectedOrder.items.map((oi) => { {activeItems.map((oi) => {
const item = itemsById.get(oi.itemId) const item = itemsById.get(oi.itemId)
return ( return (
<SelectItem key={oi.itemId} value={oi.itemId}> <SelectItem key={oi.itemId} value={oi.itemId}>
{item?.name ?? oi.itemId.slice(0, 8)} {item ? `— ${item.sku}` : ''} {oi.itemName}{item ? ` — ${item.sku}` : ''}
</SelectItem> </SelectItem>
) )
})} })}
@@ -282,7 +279,7 @@ export function OrderReturnDialog({ open, onClose, onSuccess }: OrderReturnDialo
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button> <Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || !selectedOrder}> <Button type="submit" disabled={form.formState.isSubmitting || !selectedPO}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Return'} {form.formState.isSubmitting ? 'Saving…' : 'Record Return'}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -49,7 +49,7 @@ export function OrderReturnViewSheet({ open, onClose, ret, storeName }: OrderRet
<div className="flex flex-col"> <div className="flex flex-col">
{storeName && <Row label="Store" value={storeName} />} {storeName && <Row label="Store" value={storeName} />}
<Row label="Order ID" value={<span className="font-mono text-xs">{ret.orderId}</span>} /> <Row label="Shop PO" value={<span className="font-mono text-xs">{ret.shopPOId}</span>} />
<Row label="Mode" value={<Badge>{MODE_LABEL[ret.mode]}</Badge>} /> <Row label="Mode" value={<Badge>{MODE_LABEL[ret.mode]}</Badge>} />
<Row label="Total Credit Amount" value={fmtAmount(ret.totalCreditAmount)} /> <Row label="Total Credit Amount" value={fmtAmount(ret.totalCreditAmount)} />
<Row label="Order Total After" value={fmtAmount(ret.orderTotalAmountAfter)} /> <Row label="Order Total After" value={fmtAmount(ret.orderTotalAmountAfter)} />
@@ -9,7 +9,7 @@ import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { saleReturns as saleReturnsApi, orders as ordersApi, stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { saleReturns as saleReturnsApi, shopPos as shopPosApi, stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { SaleReturnResponse, ReturnMode } from '@/types/api' import type { SaleReturnResponse, ReturnMode } from '@/types/api'
import { OrderReturnViewSheet } from './order-return-view-sheet' import { OrderReturnViewSheet } from './order-return-view-sheet'
import { OrderReturnDialog } from './order-return-dialog' import { OrderReturnDialog } from './order-return-dialog'
@@ -49,13 +49,13 @@ export function OrderReturnsClient() {
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const [returns, orderList, storeList] = await Promise.all([ const [returns, shopPOList, storeList] = await Promise.all([
saleReturnsApi.list(), saleReturnsApi.list(),
ordersApi.list(), shopPosApi.list({ pageSize: 200 }),
storesApi.list({ pageSize: 200 }), storesApi.list({ pageSize: 200 }),
]) ])
const storeNameById = new Map(getPaginatedItems(storeList).map((s) => [s.id, s.storeName])) const storeNameById = new Map(getPaginatedItems(storeList).map((s) => [s.id, s.storeName]))
const map = new Map(orderList.map((o) => [o.id, storeNameById.get(o.storeId) ?? '—'])) const map = new Map(getPaginatedItems(shopPOList).map((o) => [o.id, storeNameById.get(o.storeId) ?? o.storeName]))
setStoreNameByOrderId(map) setStoreNameByOrderId(map)
setData(returns) setData(returns)
} catch (err) { } catch (err) {
@@ -78,10 +78,10 @@ export function OrderReturnsClient() {
{ {
key: 'store', key: 'store',
title: 'Store', title: 'Store',
fields: ['orderId'], fields: ['shopPOId'],
minWidth: 200, minWidth: 200,
cellRender: (row) => ( cellRender: (row) => (
<span className="text-sm font-medium">{storeNameByOrderId.get(row.orderId) ?? '—'}</span> <span className="text-sm font-medium">{storeNameByOrderId.get(row.shopPOId) ?? '—'}</span>
), ),
}, },
{ {
@@ -163,7 +163,7 @@ export function OrderReturnsClient() {
open={viewOpen} open={viewOpen}
onClose={() => setViewOpen(false)} onClose={() => setViewOpen(false)}
ret={viewing} ret={viewing}
storeName={viewing ? storeNameByOrderId.get(viewing.orderId) : undefined} storeName={viewing ? storeNameByOrderId.get(viewing.shopPOId) : undefined}
/> />
<OrderReturnDialog <OrderReturnDialog
@@ -1,111 +1,2 @@
'use client' // Replaced by shop-po-view-sheet.tsx
export {}
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import type { SaleResponse, SaleStatus, PaymentStatus } from '@/types/api'
const SALE_STATUS_VARIANT: Record<SaleStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
Completed: 'default',
Cancelled: 'destructive',
}
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
PartiallyPaid: 'secondary',
Paid: 'default',
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
const fmtDateTime = (v: string | null) =>
v
? new Date(v).toLocaleString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
: '—'
interface SaleViewSheetProps {
open: boolean
onClose: () => void
sale: SaleResponse | null
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
)
}
export function SaleViewSheet({ open, onClose, sale }: SaleViewSheetProps) {
if (!sale) return null
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Sale Details</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<Row label="Sale ID" value={<span className="font-mono">{truncId(sale.id)}</span>} />
<Row label="Store ID" value={<span className="font-mono">{truncId(sale.storeId)}</span>} />
<Row label="Status" value={<Badge variant={SALE_STATUS_VARIANT[sale.status]}>{sale.status}</Badge>} />
<Row label="Payment Status" value={<Badge variant={PAYMENT_STATUS_VARIANT[sale.paymentStatus]}>{sale.paymentStatus}</Badge>} />
<Row label="Total Amount" value={fmtAmount(sale.totalAmount)} />
<Row label="Fulfill Immediately" value={sale.fulfillImmediately ? 'Yes' : 'No'} />
<Row label="Order Status" value={sale.orderStatus} />
<Row label="Sale Date" value={fmtDateTime(sale.saleDate)} />
<Row label="Completed At" value={fmtDateTime(sale.completedAt)} />
<Row label="Created At" value={fmtDateTime(sale.createdAt)} />
</div>
<Separator />
<div>
<p className="text-sm font-medium mb-2">Items ({sale.items.length})</p>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left font-medium px-3 py-2">Item</th>
<th className="text-right font-medium px-3 py-2">Quantity</th>
<th className="text-right font-medium px-3 py-2">Fulfilled</th>
<th className="text-right font-medium px-3 py-2">Unit Price</th>
</tr>
</thead>
<tbody>
{sale.items.length === 0 ? (
<tr>
<td colSpan={4} className="text-center text-muted-foreground px-3 py-4">
No items on this sale.
</td>
</tr>
) : (
sale.items.map((item) => (
<tr key={item.itemId} className="border-t">
<td className="px-3 py-2 font-mono">{truncId(item.itemId)}</td>
<td className="text-right px-3 py-2">{item.quantity}</td>
<td className="text-right px-3 py-2">{item.quantityFulfilled}</td>
<td className="text-right px-3 py-2">{fmtAmount(item.unitPrice)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -1,7 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons' import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -10,10 +9,11 @@ import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { sales as salesApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { shopPos as shopPosApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { SaleResponse, SaleStatus, PaymentStatus } from '@/types/api' import type { ShopPOResponse, ShopPOStatus } from '@/types/api'
import { SaleViewSheet } from './sale-view-sheet' import { ShopPOViewSheet } from './shop-po-view-sheet'
import { ShopPOCreateSheet } from './shop-po-create-sheet'
const fmtDate = (v: string) => const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -21,36 +21,31 @@ const fmtDate = (v: string) =>
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8) const truncId = (id: string) => id.substring(0, 8)
const SALE_STATUS_VARIANT: Record<SaleStatus, 'default' | 'secondary' | 'destructive'> = { const PO_STATUS_VARIANT: Record<ShopPOStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary', Pending: 'secondary',
Completed: 'default', PartiallyFulfilled: 'secondary',
Fulfilled: 'default',
Cancelled: 'destructive', Cancelled: 'destructive',
} }
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
PartiallyPaid: 'secondary',
Paid: 'default',
}
export function SalesClient() { export function SalesClient() {
const router = useRouter() const [data, setData] = useState<ShopPOResponse[]>([])
const [data, setData] = useState<SaleResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [viewOpen, setViewOpen] = useState(false) const [viewOpen, setViewOpen] = useState(false)
const [selectedSale, setSelectedSale] = useState<SaleResponse | null>(null) const [createOpen, setCreateOpen] = useState(false)
const [selectedPO, setSelectedPO] = useState<ShopPOResponse | null>(null)
const fetchData = useCallback(async (p: number, ps: number) => { const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true) setLoading(true)
try { try {
const res = await salesApi.list({ page: p, pageSize: ps }) const res = await shopPosApi.list({ page: p, pageSize: ps })
setData(getPaginatedItems(res)) setData(getPaginatedItems(res))
setTotal(res.total) setTotal(res.total)
} catch (err) { } catch (err) {
toast.add({ title: 'Failed to load sales', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to load Shop POs', description: getApiError(err), type: 'error' })
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -60,45 +55,52 @@ export function SalesClient() {
fetchData(page, pageSize) fetchData(page, pageSize)
}, [fetchData, page, pageSize]) }, [fetchData, page, pageSize])
function handleView(row: SaleResponse) { function handleView(row: ShopPOResponse) {
setSelectedSale(row) setSelectedPO(row)
setViewOpen(true) setViewOpen(true)
} }
async function handleCancel(row: SaleResponse) { async function handleConfirm(row: ShopPOResponse) {
try { try {
await salesApi.cancel(row.id) await shopPosApi.confirm(row.id)
toast.add({ title: 'Sale cancelled', type: 'success' }) toast.add({ title: 'Shop PO confirmed', type: 'success' })
fetchData(page, pageSize) fetchData(page, pageSize)
} catch (err) { } catch (err) {
toast.add({ title: 'Failed to cancel sale', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' })
} }
} }
const tableConfig = useMemo<TableConfig<SaleResponse>>(() => ({ async function handleCancel(row: ShopPOResponse) {
try {
await shopPosApi.cancel(row.id)
toast.add({ title: 'Shop PO cancelled', type: 'success' })
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to cancel Shop PO', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<ShopPOResponse>>(() => ({
rowKey: 'id', rowKey: 'id',
serverSide: true, serverSide: true,
stickyHeader: true, stickyHeader: true,
emptyMessage: 'No sales found.', emptyMessage: 'No Shop POs found.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [ columns: [
{ {
key: 'id', key: 'id',
title: 'Sale ID', title: 'PO ID',
fields: ['id'], fields: ['id'],
width: 140, width: 120,
cellRender: (row) => ( cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.id)}</span> <span className="font-mono text-sm">{truncId(row.id)}</span>
), ),
}, },
{ {
key: 'storeId', key: 'store',
title: 'Store ID', title: 'Store',
fields: ['storeId'], fields: [{ key: 'storeName', primary: true }],
width: 140, minWidth: 180,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.storeId)}</span>
),
}, },
{ {
key: 'totalAmount', key: 'totalAmount',
@@ -113,25 +115,21 @@ export function SalesClient() {
key: 'status', key: 'status',
title: 'Status', title: 'Status',
fields: ['status'], fields: ['status'],
width: 140,
cellRender: (row) => (
<Badge variant={SALE_STATUS_VARIANT[row.status]}>{row.status}</Badge>
),
},
{
key: 'paymentStatus',
title: 'Payment',
fields: ['paymentStatus'],
width: 160, width: 160,
cellRender: (row) => ( cellRender: (row) => (
<Badge variant={PAYMENT_STATUS_VARIANT[row.paymentStatus]}>{row.paymentStatus}</Badge> <Badge variant={PO_STATUS_VARIANT[row.status]}>{row.status}</Badge>
), ),
}, },
{ {
key: 'saleDate', key: 'confirmed',
title: 'Sale Date', title: 'Confirmed',
fields: [{ key: 'saleDate', transform: (v) => fmtDate(v as string) }], fields: ['confirmedAt'],
width: 140, width: 120,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">
{row.confirmedAt ? fmtDate(row.confirmedAt) : 'Unconfirmed'}
</span>
),
}, },
{ {
key: 'createdAt', key: 'createdAt',
@@ -144,14 +142,20 @@ export function SalesClient() {
actions: () => [ actions: () => [
{ {
action: 'view', action: 'view',
label: 'View sale', label: 'View PO',
onClick: handleView, onClick: handleView,
}, },
{
action: 'confirm',
label: 'Confirm PO',
onClick: handleConfirm,
visible: (target) => !target.confirmedAt && target.status !== 'Cancelled',
},
{ {
action: 'cancel', action: 'cancel',
label: 'Cancel sale', label: 'Cancel PO',
onClick: handleCancel, onClick: handleCancel,
visible: (target) => target.status !== 'Cancelled', visible: (target) => target.status !== 'Cancelled' && target.status !== 'Fulfilled',
}, },
], ],
}, },
@@ -161,14 +165,14 @@ export function SalesClient() {
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">Sales</h1> <h1 className="text-2xl font-semibold text-foreground">Shop POs</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
Sales transactions recorded against stores Purchase orders raised by agents on behalf of stores
</p> </p>
</div> </div>
<Button onClick={() => router.push('/sales/new')}> <Button onClick={() => setCreateOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} /> <HugeiconsIcon icon={Add01Icon} size={16} />
Add Sale New PO
</Button> </Button>
</div> </div>
@@ -183,10 +187,16 @@ export function SalesClient() {
}} }}
/> />
<SaleViewSheet <ShopPOCreateSheet
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
<ShopPOViewSheet
open={viewOpen} open={viewOpen}
onClose={() => setViewOpen(false)} onClose={() => setViewOpen(false)}
sale={selectedSale} po={selectedPO}
/> />
</> </>
) )
@@ -0,0 +1,217 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { shopPos as shopPosApi, stores as storesApi, items as itemsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StoreResponse, ItemSummaryResponse } from '@/types/api'
const itemLineSchema = z.object({
itemId: z.string().min(1, 'Item required'),
quantity: z.number().int().min(1, 'Min 1'),
unitPrice: z.number().min(0, 'Price must be 0 or more'),
})
const schema = z.object({
storeId: z.string().min(1, 'Store is required'),
items: z.array(itemLineSchema).min(1, 'At least one item is required'),
})
type FormValues = z.infer<typeof schema>
interface ShopPOCreateSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
const emptyLine = { itemId: '', quantity: 1, unitPrice: 0 }
export function ShopPOCreateSheet({ open, onClose, onSuccess }: ShopPOCreateSheetProps) {
const [storesList, setStoresList] = useState<StoreResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { storeId: '', items: [emptyLine] },
})
const watchedItems = form.watch('items')
useEffect(() => {
if (!open) return
form.reset({ storeId: '', items: [emptyLine] })
Promise.all([
storesApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
])
.then(([s, i]) => {
setStoresList(getPaginatedItems(s))
setItemsList(getPaginatedItems(i))
})
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
}, [open])
function addLine() {
form.setValue('items', [...watchedItems, emptyLine])
}
function removeLine(idx: number) {
form.setValue('items', watchedItems.filter((_, i) => i !== idx))
}
async function onSubmit(values: FormValues) {
try {
await shopPosApi.create(values)
toast.add({ title: 'Shop PO created', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to create Shop PO', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>New Shop PO</DialogTitle>
<DialogDescription>Raise a purchase order on behalf of a store.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="storeId"
label="Store"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select store..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select store...</SelectItem>
{storesList.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.storeName} — {s.ownerName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium">Line Items</p>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
+ Add Item
</Button>
</div>
<div className="flex flex-col gap-3">
{watchedItems.map((_, idx) => (
<div key={idx} className="grid grid-cols-[1fr_80px_100px_36px] gap-2 items-end">
<FormField
control={form.control}
name={`items.${idx}.itemId`}
label={idx === 0 ? 'Item' : undefined}
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select item...</SelectItem>
{itemsList.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name} ({item.sku})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name={`items.${idx}.quantity`}
label={idx === 0 ? 'Qty' : undefined}
render={({ field, invalid }) => (
<Input
type="number"
min={1}
value={Number(field.value as number)}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${idx}.unitPrice`}
label={idx === 0 ? 'Unit Price' : undefined}
render={({ field, invalid }) => (
<Input
type="number"
step="0.01"
min={0}
value={Number(field.value as number)}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<div className={idx === 0 ? 'mt-6' : ''}>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeLine(idx)}
disabled={watchedItems.length === 1}
className="h-9 w-9 p-0 text-muted-foreground hover:text-destructive"
>
×
</Button>
</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating…' : 'Create PO'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,106 @@
'use client'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import type { ShopPOResponse, ShopPOStatus } from '@/types/api'
const PO_STATUS_VARIANT: Record<ShopPOStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
PartiallyFulfilled: 'secondary',
Fulfilled: 'default',
Cancelled: 'destructive',
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
const fmtDateTime = (v: string | null) =>
v
? new Date(v).toLocaleString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
: '—'
interface ShopPOViewSheetProps {
open: boolean
onClose: () => void
po: ShopPOResponse | null
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
)
}
export function ShopPOViewSheet({ open, onClose, po }: ShopPOViewSheetProps) {
if (!po) return null
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Shop PO Details</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<Row label="PO ID" value={<span className="font-mono">{truncId(po.id)}</span>} />
<Row label="Store" value={po.storeName} />
<Row label="Status" value={<Badge variant={PO_STATUS_VARIANT[po.status]}>{po.status}</Badge>} />
<Row label="Total Amount" value={fmtAmount(po.totalAmount)} />
<Row label="Invoice" value={po.invoiceId ? <span className="font-mono">{truncId(po.invoiceId)}</span> : 'No invoice yet'} />
<Row label="Confirmed At" value={fmtDateTime(po.confirmedAt)} />
<Row label="Fulfilled At" value={fmtDateTime(po.fulfilledAt)} />
<Row label="Created At" value={fmtDateTime(po.createdAt)} />
</div>
<Separator />
<div>
<p className="text-sm font-medium mb-2">Line Items ({po.items.filter(i => i.isActive).length})</p>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left font-medium px-3 py-2">Item</th>
<th className="text-right font-medium px-3 py-2">Qty</th>
<th className="text-right font-medium px-3 py-2">Fulfilled</th>
<th className="text-right font-medium px-3 py-2">Unit Price</th>
<th className="text-right font-medium px-3 py-2">Line Total</th>
</tr>
</thead>
<tbody>
{po.items.filter(i => i.isActive).length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-muted-foreground px-3 py-4">
No items on this PO.
</td>
</tr>
) : (
po.items.filter(i => i.isActive).map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-2">{item.itemName}</td>
<td className="text-right px-3 py-2">{item.quantity}</td>
<td className="text-right px-3 py-2">{item.quantityFulfilled}</td>
<td className="text-right px-3 py-2">{fmtAmount(item.unitPrice)}</td>
<td className="text-right px-3 py-2 font-medium">{fmtAmount(item.quantity * item.unitPrice)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -15,24 +15,21 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field' import { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { import {
sales as salesApi, shopPos as shopPosApi,
stores as storesApi, stores as storesApi,
rides as ridesApi,
items as itemsApi, items as itemsApi,
users as usersApi,
priceLists as priceListsApi, priceLists as priceListsApi,
getApiError, getApiError,
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } from '@/lib/api/index'
import type { ItemSummaryResponse, RideResponse, StoreResponse, UserResponse } from '@/types/api' import type { ItemSummaryResponse, StoreResponse } from '@/types/api'
const saleItemSchema = z.object({ const lineItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'), itemId: z.string().min(1, 'Item is required'),
quantity: z.number().min(1, 'Quantity must be at least 1'), quantity: z.number().min(1, 'Quantity must be at least 1'),
unitPrice: z.number().min(0.01, 'Unit price must be greater than 0'), unitPrice: z.number().min(0.01, 'Unit price must be greater than 0'),
@@ -40,11 +37,7 @@ const saleItemSchema = z.object({
const schema = z.object({ const schema = z.object({
storeId: z.string().min(1, 'Store is required'), storeId: z.string().min(1, 'Store is required'),
saleDate: z.string().min(1, 'Sale date is required'), items: z.array(lineItemSchema).min(1, 'At least one item is required'),
createdByUserId: z.string().min(1, 'Created by is required'),
rideId: z.string(),
fulfillImmediately: z.boolean(),
items: z.array(saleItemSchema).min(1, 'At least one item is required'),
}) })
type FormValues = z.infer<typeof schema> type FormValues = z.infer<typeof schema>
@@ -53,61 +46,46 @@ function createDefaultItem() {
return { itemId: '', quantity: 1, unitPrice: 0 } return { itemId: '', quantity: 1, unitPrice: 0 }
} }
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
export default function NewSalePage() { export default function NewShopPOPage() {
const router = useRouter() const router = useRouter()
const [storesList, setStoresList] = useState<StoreResponse[]>([]) const [storesList, setStoresList] = useState<StoreResponse[]>([])
const [ridesList, setRidesList] = useState<RideResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([]) const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([]) const [priceMinByIndex, setPriceMinByIndex] = useState<Record<number, number | null>>({})
const [priceRetailByIndex, setPriceRetailByIndex] = useState<Record<number, number | null>>({})
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: { storeId: '', items: [createDefaultItem()] },
storeId: '',
saleDate: todayIso(),
createdByUserId: '',
rideId: '',
fulfillImmediately: false,
items: [createDefaultItem()],
},
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'items',
}) })
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
const watchedItems = useWatch({ control: form.control, name: 'items' }) const watchedItems = useWatch({ control: form.control, name: 'items' })
const watchedStoreId = useWatch({ control: form.control, name: 'storeId' }) const watchedStoreId = useWatch({ control: form.control, name: 'storeId' })
const watchedSaleDate = useWatch({ control: form.control, name: 'saleDate' })
const itemsById = useMemo( const itemsById = useMemo(
() => new Map(itemsList.map((item) => [item.id, item])), () => new Map(itemsList.map((item) => [item.id, item])),
[itemsList] [itemsList]
) )
const totalAmount = useMemo( const totalAmount = useMemo(
() => watchedItems.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0), 0), () => watchedItems.reduce((sum, line) => sum + (Number(line.quantity) || 0) * (Number(line.unitPrice) || 0), 0),
[watchedItems] [watchedItems]
) )
const [priceMinByIndex, setPriceMinByIndex] = useState<Record<number, number | null>>({})
const [priceRetailByIndex, setPriceRetailByIndex] = useState<Record<number, number | null>>({})
async function resolveAndSetPrice(index: number, itemId: string) { async function resolveAndSetPrice(index: number, itemId: string) {
const item = itemsById.get(itemId) const item = itemsById.get(itemId)
if (watchedStoreId && watchedSaleDate) { const today = new Date().toISOString().slice(0, 10)
if (watchedStoreId) {
try { try {
const resolution = await priceListsApi.resolve({ itemId, storeId: watchedStoreId, saleDate: watchedSaleDate }) const resolution = await priceListsApi.resolve({ itemId, storeId: watchedStoreId, saleDate: today })
form.setValue(`items.${index}.unitPrice`, resolution.price, { shouldValidate: true }) form.setValue(`items.${index}.unitPrice`, resolution.price, { shouldValidate: true })
setPriceMinByIndex((prev) => ({ ...prev, [index]: resolution.minPrice })) setPriceMinByIndex((prev) => ({ ...prev, [index]: resolution.minPrice }))
setPriceRetailByIndex((prev) => ({ ...prev, [index]: resolution.retailPrice })) setPriceRetailByIndex((prev) => ({ ...prev, [index]: resolution.retailPrice }))
return return
} catch { } catch {
// No resolvable price for this store/date (e.g. store/item not found) — fall back below. // No resolvable price for this store — fall back to item default.
} }
} }
if (item) { if (item) {
@@ -117,28 +95,22 @@ export default function NewSalePage() {
} }
} }
// Re-resolve every already-selected line's price whenever the store or sale date changes,
// since the applicable price list is scoped by the store's region and the sale date.
useEffect(() => { useEffect(() => {
if (!watchedStoreId || !watchedSaleDate) return if (!watchedStoreId) return
watchedItems.forEach((line, index) => { watchedItems.forEach((line, index) => {
if (line.itemId) resolveAndSetPrice(index, line.itemId) if (line.itemId) resolveAndSetPrice(index, line.itemId)
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchedStoreId, watchedSaleDate]) }, [watchedStoreId])
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
storesApi.list({ pageSize: 200 }), storesApi.list({ pageSize: 200 }),
ridesApi.list({ pageSize: 200, rideType: 'Sale' }),
itemsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
]) ])
.then(([stores, rides, items, usersRes]) => { .then(([stores, items]) => {
setStoresList(getPaginatedItems(stores)) setStoresList(getPaginatedItems(stores))
setRidesList(getPaginatedItems(rides))
setItemsList(getPaginatedItems(items)) setItemsList(getPaginatedItems(items))
setUsersList(getPaginatedItems(usersRes))
}) })
.catch((err) => { .catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
@@ -147,18 +119,14 @@ export default function NewSalePage() {
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
try { try {
await salesApi.create({ await shopPosApi.create({
storeId: values.storeId, storeId: values.storeId,
saleDate: values.saleDate,
rideId: values.rideId || null,
fulfillImmediately: values.fulfillImmediately,
items: values.items, items: values.items,
createdByUserId: values.createdByUserId,
}) })
toast.add({ title: 'Sale created', type: 'success' }) toast.add({ title: 'Shop PO created', type: 'success' })
router.push('/sales') router.push('/sales')
} catch (err) { } catch (err) {
toast.add({ title: 'Failed to create sale', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to create Shop PO', description: getApiError(err), type: 'error' })
} }
} }
@@ -170,16 +138,15 @@ export default function NewSalePage() {
Back Back
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">Create Sale</h1> <h1 className="text-2xl font-semibold text-foreground">Create Shop PO</h1>
<p className="text-sm text-muted-foreground">Record a sale for a store with one or more items.</p> <p className="text-sm text-muted-foreground">Record a purchase order for a store.</p>
</div> </div>
</div> </div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<div className="rounded-2xl border p-6"> <div className="rounded-2xl border p-6">
<h3 className="mb-4 text-sm font-medium text-foreground">Sale Details</h3> <h3 className="mb-4 text-sm font-medium text-foreground">Order Details</h3>
<FieldGroup> <FieldGroup>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<FormField <FormField
control={form.control} control={form.control}
name="storeId" name="storeId"
@@ -194,94 +161,13 @@ export default function NewSalePage() {
<SelectItem value="" disabled>Select store...</SelectItem> <SelectItem value="" disabled>Select store...</SelectItem>
{storesList.map((store) => ( {storesList.map((store) => (
<SelectItem key={store.id} value={store.id}> <SelectItem key={store.id} value={store.id}>
{store.storeName} - {store.ownerName} {store.storeName} — {store.ownerName}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
/> />
<FormField
control={form.control}
name="saleDate"
label="Sale Date"
required
render={({ field, invalid }) => (
<Input
id="saleDate"
name={field.name}
ref={field.ref}
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="createdByUserId"
label="Created By"
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select user...</SelectItem>
{usersList.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.fullName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="rideId"
label="Ride"
render={({ field }) => (
<Select
value={(field.value as string) || '__none__'}
onValueChange={(value) => field.onChange(value === '__none__' ? '' : value)}
>
<SelectTrigger>
<SelectValue placeholder="Select ride..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No ride</SelectItem>
{ridesList.map((ride) => (
<SelectItem key={ride.id} value={ride.id}>
{ride.routeName} - {ride.vehicleNumberPlate}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<FormField
control={form.control}
name="fulfillImmediately"
render={({ field }) => (
<label className="flex items-center gap-3 text-sm font-medium">
<Checkbox
checked={Boolean(field.value)}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
Fulfill immediately (van sale)
</label>
)}
/>
</FieldGroup> </FieldGroup>
</div> </div>
@@ -313,7 +199,9 @@ export default function NewSalePage() {
<tbody> <tbody>
{fields.map((fieldItem, index) => { {fields.map((fieldItem, index) => {
const selectedItem = itemsById.get(watchedItems[index]?.itemId ?? '') const selectedItem = itemsById.get(watchedItems[index]?.itemId ?? '')
const lineTotal = (Number(watchedItems[index]?.quantity) || 0) * (Number(watchedItems[index]?.unitPrice) || 0) const lineTotal =
(Number(watchedItems[index]?.quantity) || 0) *
(Number(watchedItems[index]?.unitPrice) || 0)
return ( return (
<tr key={fieldItem.id} className="border-t align-top"> <tr key={fieldItem.id} className="border-t align-top">
@@ -336,7 +224,7 @@ export default function NewSalePage() {
<SelectItem value="" disabled>Select item...</SelectItem> <SelectItem value="" disabled>Select item...</SelectItem>
{itemsList.map((item) => ( {itemsList.map((item) => (
<SelectItem key={item.id} value={item.id}> <SelectItem key={item.id} value={item.id}>
{item.name} - {item.sku} {item.name} — {item.sku}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -356,9 +244,6 @@ export default function NewSalePage() {
name={`items.${index}.quantity`} name={`items.${index}.quantity`}
render={({ field, invalid }) => ( render={({ field, invalid }) => (
<Input <Input
id={`items.${index}.quantity`}
name={field.name}
ref={field.ref}
type="number" type="number"
min={1} min={1}
value={Number(field.value as number)} value={Number(field.value as number)}
@@ -377,9 +262,6 @@ export default function NewSalePage() {
name={`items.${index}.unitPrice`} name={`items.${index}.unitPrice`}
render={({ field, invalid }) => ( render={({ field, invalid }) => (
<Input <Input
id={`items.${index}.unitPrice`}
name={field.name}
ref={field.ref}
type="number" type="number"
min={0.01} min={0.01}
step="0.01" step="0.01"
@@ -393,12 +275,12 @@ export default function NewSalePage() {
/> />
{priceMinByIndex[index] != null && ( {priceMinByIndex[index] != null && (
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Negotiable down to {fmtAmount(priceMinByIndex[index]!)} Min: {fmtAmount(priceMinByIndex[index]!)}
</p> </p>
)} )}
{priceRetailByIndex[index] != null && ( {priceRetailByIndex[index] != null && (
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Retail price: {fmtAmount(priceRetailByIndex[index]!)} Retail: {fmtAmount(priceRetailByIndex[index]!)}
</p> </p>
)} )}
</td> </td>
@@ -426,9 +308,9 @@ export default function NewSalePage() {
<div className="flex justify-end"> <div className="flex justify-end">
<div className="w-full rounded-2xl border p-6 sm:w-96"> <div className="w-full rounded-2xl border p-6 sm:w-96">
<h3 className="text-sm font-medium text-foreground">Sale Summary</h3> <h3 className="text-sm font-medium text-foreground">Order Summary</h3>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{watchedItems.length} item{watchedItems.length === 1 ? '' : 's'} on this sale. {watchedItems.length} item{watchedItems.length === 1 ? '' : 's'} on this order.
</p> </p>
<Separator className="my-4" /> <Separator className="my-4" />
@@ -440,7 +322,7 @@ export default function NewSalePage() {
<div className="mt-6 flex flex-col gap-2"> <div className="mt-6 flex flex-col gap-2">
<Button type="submit" disabled={form.formState.isSubmitting}> <Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating...' : 'Create Sale'} {form.formState.isSubmitting ? 'Creating...' : 'Create Shop PO'}
</Button> </Button>
<Button type="button" variant="outline" onClick={() => router.push('/sales')}> <Button type="button" variant="outline" onClick={() => router.push('/sales')}>
Cancel Cancel
@@ -1,111 +1,2 @@
'use client' // Retained for compatibility — invoice status transitions are now handled inline via table row actions.
export {}
import { useEffect } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { orders as ordersApi, getApiError } from '@/lib/api/index'
import type { OrderResponse, OrderStatus } from '@/types/api'
const STATUS_OPTIONS: OrderStatus[] = ['Pending', 'PartiallyFulfilled', 'Fulfilled', 'Cancelled']
const schema = z.object({
status: z.enum(['Pending', 'PartiallyFulfilled', 'Fulfilled', 'Cancelled']),
})
type FormValues = z.infer<typeof schema>
interface OrderEditStatusDialogProps {
open: boolean
onClose: () => void
onSuccess: () => void
order: OrderResponse | null
}
export function OrderEditStatusDialog({ open, onClose, onSuccess, order }: OrderEditStatusDialogProps) {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { status: 'Pending' },
})
useEffect(() => {
if (open && order) {
form.reset({ status: order.status })
}
}, [open, order])
if (!order) return null
async function onSubmit(values: FormValues) {
try {
await ordersApi.update(order!.id, values.status)
toast.add({ title: 'Order status updated', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to update order', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit Order Status</DialogTitle>
<DialogDescription>
Changing this also updates the linked sale — Fulfilled completes the sale, Cancelled cancels it.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="status"
label="Status"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select status..." />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -7,14 +7,18 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator' import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
import type { OrderResponse, OrderStatus } from '@/types/api'
const ORDER_STATUS_VARIANT: Record<OrderStatus, 'default' | 'secondary' | 'destructive'> = { const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'destructive'> = {
Draft: 'secondary',
Printed: 'secondary',
Finalized: 'default',
}
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary', Pending: 'secondary',
PartiallyFulfilled: 'secondary', PartiallyPaid: 'secondary',
Fulfilled: 'default', Paid: 'default',
Cancelled: 'destructive',
} }
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
@@ -30,8 +34,7 @@ const fmtDateTime = (v: string | null) =>
interface OrderViewSheetProps { interface OrderViewSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
order: OrderResponse | null invoice: InvoiceResponse | null
storeName?: string
} }
function Row({ label, value }: { label: string; value: React.ReactNode }) { function Row({ label, value }: { label: string; value: React.ReactNode }) {
@@ -43,61 +46,26 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
) )
} }
export function OrderViewSheet({ open, onClose, order, storeName }: OrderViewSheetProps) { export function OrderViewSheet({ open, onClose, invoice }: OrderViewSheetProps) {
if (!order) return null if (!invoice) return null
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-2xl"> <DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Order Details</DialogTitle> <DialogTitle>Invoice Details</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex flex-col"> <div className="flex flex-col">
<Row label="Order ID" value={<span className="font-mono">{truncId(order.id)}</span>} /> <Row label="Invoice ID" value={<span className="font-mono">{truncId(invoice.id)}</span>} />
<Row label="Sale ID" value={<span className="font-mono">{truncId(order.saleId)}</span>} /> <Row label="Shop PO" value={<span className="font-mono">{truncId(invoice.shopPOId)}</span>} />
<Row label="Store" value={storeName ?? <span className="font-mono">{truncId(order.storeId)}</span>} /> <Row label="Store" value={invoice.storeName} />
<Row label="Status" value={<Badge variant={ORDER_STATUS_VARIANT[order.status]}>{order.status}</Badge>} /> <Row label="Status" value={<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>{invoice.status}</Badge>} />
<Row label="Total Amount" value={fmtAmount(order.totalAmount)} /> <Row label="Payment" value={<Badge variant={PAYMENT_STATUS_VARIANT[invoice.paymentStatus]}>{invoice.paymentStatus}</Badge>} />
<Row label="Order Date" value={fmtDateTime(order.orderDate)} /> <Row label="Total Amount" value={fmtAmount(invoice.totalAmount)} />
<Row label="Fulfilled At" value={fmtDateTime(order.fulfilledAt)} /> <Row label="Printed At" value={fmtDateTime(invoice.printedAt)} />
<Row label="Created At" value={fmtDateTime(order.createdAt)} /> <Row label="Finalized At" value={fmtDateTime(invoice.finalizedAt)} />
</div> <Row label="Created At" value={fmtDateTime(invoice.createdAt)} />
<Separator />
<div>
<p className="text-sm font-medium mb-2">Items ({order.items.length})</p>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left font-medium px-3 py-2">Item</th>
<th className="text-right font-medium px-3 py-2">Quantity</th>
<th className="text-right font-medium px-3 py-2">Fulfilled</th>
<th className="text-right font-medium px-3 py-2">Unit Price</th>
</tr>
</thead>
<tbody>
{order.items.length === 0 ? (
<tr>
<td colSpan={4} className="text-center text-muted-foreground px-3 py-4">
No items on this order.
</td>
</tr>
) : (
order.items.map((item) => (
<tr key={item.itemId} className="border-t">
<td className="px-3 py-2 font-mono">{truncId(item.itemId)}</td>
<td className="text-right px-3 py-2">{item.quantity}</td>
<td className="text-right px-3 py-2">{item.quantityFulfilled}</td>
<td className="text-right px-3 py-2">{fmtAmount(item.unitPrice)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -6,11 +6,10 @@ import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table' import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { orders as ordersApi, stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { invoices as invoicesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { OrderResponse, OrderStatus, StoreResponse } from '@/types/api' import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
import { OrderViewSheet } from './order-view-sheet' import { OrderViewSheet } from './order-view-sheet'
import { OrderEditStatusDialog } from './order-edit-status-dialog'
const fmtDate = (v: string) => const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -18,78 +17,80 @@ const fmtDate = (v: string) =>
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8) const truncId = (id: string) => id.substring(0, 8)
const ORDER_STATUS_VARIANT: Record<OrderStatus, 'default' | 'secondary' | 'destructive'> = { const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'destructive'> = {
Draft: 'secondary',
Printed: 'secondary',
Finalized: 'default',
}
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary', Pending: 'secondary',
PartiallyFulfilled: 'secondary', PartiallyPaid: 'secondary',
Fulfilled: 'default', Paid: 'default',
Cancelled: 'destructive',
} }
export function OrdersClient() { export function OrdersClient() {
const [data, setData] = useState<OrderResponse[]>([]) const [data, setData] = useState<InvoiceResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [viewOpen, setViewOpen] = useState(false) const [viewOpen, setViewOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false) const [selectedInvoice, setSelectedInvoice] = useState<InvoiceResponse | null>(null)
const [selectedOrder, setSelectedOrder] = useState<OrderResponse | null>(null)
const [storesList, setStoresList] = useState<StoreResponse[]>([])
const storesById = useMemo( const fetchData = useCallback(async (p: number, ps: number) => {
() => new Map(storesList.map((s) => [s.id, s.storeName])),
[storesList]
)
const fetchData = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const items = await ordersApi.list() const res = await invoicesApi.list({ page: p, pageSize: ps })
setData(items) setData(getPaginatedItems(res))
setTotal(items.length) setTotal(res.total)
} catch (err) { } catch (err) {
toast.add({ title: 'Failed to load orders', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to load invoices', description: getApiError(err), type: 'error' })
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
fetchData() fetchData(page, pageSize)
storesApi.list({ pageSize: 200 }) }, [fetchData, page, pageSize])
.then((res) => setStoresList(getPaginatedItems(res)))
.catch((err) => toast.add({ title: 'Failed to load stores', description: getApiError(err), type: 'error' }))
}, [fetchData])
function handleView(row: OrderResponse) { function handleView(row: InvoiceResponse) {
setSelectedOrder(row) setSelectedInvoice(row)
setViewOpen(true) setViewOpen(true)
} }
function handleEdit(row: OrderResponse) { async function handlePrint(row: InvoiceResponse) {
setSelectedOrder(row)
setEditOpen(true)
}
async function handleDelete(row: OrderResponse) {
try { try {
await ordersApi.delete(row.id) await invoicesApi.print(row.id)
toast.add({ title: 'Order cancelled', type: 'success' }) toast.add({ title: 'Invoice marked as printed', type: 'success' })
fetchData() fetchData(page, pageSize)
} catch (err) { } catch (err) {
toast.add({ title: 'Failed to cancel order', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to print invoice', description: getApiError(err), type: 'error' })
} }
} }
const tableConfig = useMemo<TableConfig<OrderResponse>>(() => ({ async function handleFinalize(row: InvoiceResponse) {
try {
await invoicesApi.finalize(row.id)
toast.add({ title: 'Invoice finalized', type: 'success' })
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to finalize invoice', description: getApiError(err), type: 'error' })
}
}
const tableConfig = useMemo<TableConfig<InvoiceResponse>>(() => ({
rowKey: 'id', rowKey: 'id',
serverSide: true,
stickyHeader: true, stickyHeader: true,
emptyMessage: 'No orders found.', emptyMessage: 'No invoices found.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [ columns: [
{ {
key: 'id', key: 'id',
title: 'Order ID', title: 'Invoice ID',
fields: ['id'], fields: ['id'],
width: 140, width: 140,
cellRender: (row) => ( cellRender: (row) => (
@@ -97,22 +98,10 @@ export function OrdersClient() {
), ),
}, },
{ {
key: 'saleId', key: 'store',
title: 'Sale ID',
fields: ['saleId'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.saleId)}</span>
),
},
{
key: 'storeId',
title: 'Store', title: 'Store',
fields: ['storeId'], fields: [{ key: 'storeName', primary: true }],
minWidth: 160, minWidth: 180,
cellRender: (row) => (
<span className="text-sm">{storesById.get(row.storeId) ?? truncId(row.storeId)}</span>
),
}, },
{ {
key: 'totalAmount', key: 'totalAmount',
@@ -127,16 +116,19 @@ export function OrdersClient() {
key: 'status', key: 'status',
title: 'Status', title: 'Status',
fields: ['status'], fields: ['status'],
width: 180, width: 140,
cellRender: (row) => ( cellRender: (row) => (
<Badge variant={ORDER_STATUS_VARIANT[row.status]}>{row.status}</Badge> <Badge variant={INVOICE_STATUS_VARIANT[row.status]}>{row.status}</Badge>
), ),
}, },
{ {
key: 'orderDate', key: 'paymentStatus',
title: 'Order Date', title: 'Payment',
fields: [{ key: 'orderDate', transform: (v) => fmtDate(v as string) }], fields: ['paymentStatus'],
width: 140, width: 150,
cellRender: (row) => (
<Badge variant={PAYMENT_STATUS_VARIANT[row.paymentStatus]}>{row.paymentStatus}</Badge>
),
}, },
{ {
key: 'createdAt', key: 'createdAt',
@@ -149,32 +141,32 @@ export function OrdersClient() {
actions: () => [ actions: () => [
{ {
action: 'view', action: 'view',
label: 'View order', label: 'View invoice',
onClick: handleView, onClick: handleView,
}, },
{ {
action: 'edit', action: 'print',
label: 'Edit status', label: 'Mark as printed',
onClick: handleEdit, onClick: handlePrint,
visible: (target) => target.status !== 'Cancelled', visible: (target) => target.status === 'Draft',
}, },
{ {
action: 'cancel', action: 'finalize',
label: 'Delete order', label: 'Finalize invoice',
onClick: handleDelete, onClick: handleFinalize,
visible: (target) => target.status !== 'Cancelled', visible: (target) => target.status === 'Printed',
}, },
], ],
}, },
}), [pageSize, storesById]) }), [pageSize])
return ( return (
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">Orders</h1> <h1 className="text-2xl font-semibold text-foreground">Invoices</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
Fulfillment status of store orders Billing documents generated from confirmed Shop POs
</p> </p>
</div> </div>
</div> </div>
@@ -185,6 +177,7 @@ export function OrdersClient() {
loading={loading} loading={loading}
totalCount={total} totalCount={total}
onServerSideChange={({ page: p }) => { onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize) setPageSize(p.pageSize)
}} }}
/> />
@@ -192,15 +185,7 @@ export function OrdersClient() {
<OrderViewSheet <OrderViewSheet
open={viewOpen} open={viewOpen}
onClose={() => setViewOpen(false)} onClose={() => setViewOpen(false)}
order={selectedOrder} invoice={selectedInvoice}
storeName={selectedOrder ? storesById.get(selectedOrder.storeId) : undefined}
/>
<OrderEditStatusDialog
open={editOpen}
onClose={() => setEditOpen(false)}
onSuccess={fetchData}
order={selectedOrder}
/> />
</> </>
) )
@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect } from 'react'
import { useForm, zodResolver, z } from '@/lib/form' import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field' import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -22,14 +22,13 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field' import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { payments as paymentsApi, sales as salesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { payments as paymentsApi, getApiError } from '@/lib/api/index'
import type { SaleResponse, UserResponse } from '@/types/api' import type { InvoiceResponse } from '@/types/api'
const schema = z.object({ const schema = z.object({
saleId: z.string().min(1, 'Sale is required'), invoiceId: z.string().min(1, 'Invoice is required'),
amount: z.number().min(0.01, 'Amount must be greater than 0'), amount: z.number().min(0.01, 'Amount must be greater than 0'),
method: z.enum(['Cash', 'Credit']), method: z.enum(['Cash', 'Credit']),
collectedByUserId: z.string().min(1, 'Collected by is required'),
}) })
type FormValues = z.infer<typeof schema> type FormValues = z.infer<typeof schema>
@@ -38,51 +37,31 @@ interface PaymentSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
onSuccess: () => void onSuccess: () => void
invoices: InvoiceResponse[]
} }
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8) const truncId = (id: string) => id.substring(0, 8)
export function PaymentSheet({ open, onClose, onSuccess }: PaymentSheetProps) { export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentSheetProps) {
const [salesList, setSalesList] = useState<SaleResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
saleId: '', invoiceId: '',
amount: 0, amount: 0,
method: 'Cash', method: 'Cash',
collectedByUserId: '',
}, },
}) })
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
form.reset({ invoiceId: '', amount: 0, method: 'Cash' })
form.reset({
saleId: '',
amount: 0,
method: 'Cash',
collectedByUserId: '',
})
Promise.all([
salesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(([salesRes, usersRes]) => {
setSalesList(getPaginatedItems(salesRes).filter((s) => s.status !== 'Cancelled'))
setUsersList(getPaginatedItems(usersRes))
})
.catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
})
}, [open]) }, [open])
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
const { invoiceId, ...rest } = values
try { try {
await paymentsApi.create(values) await paymentsApi.create(invoiceId, rest)
toast.add({ title: 'Payment recorded', type: 'success' }) toast.add({ title: 'Payment recorded', type: 'success' })
onSuccess() onSuccess()
onClose() onClose()
@@ -91,31 +70,33 @@ export function PaymentSheet({ open, onClose, onSuccess }: PaymentSheetProps) {
} }
} }
const pendingInvoices = invoices.filter((inv) => inv.paymentStatus !== 'Paid')
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg"> <DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Record Payment</DialogTitle> <DialogTitle>Record Payment</DialogTitle>
<DialogDescription>Record a cash or credit payment against a sale.</DialogDescription> <DialogDescription>Record a cash or credit payment against an invoice.</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup> <FieldGroup>
<FormField <FormField
control={form.control} control={form.control}
name="saleId" name="invoiceId"
label="Sale" label="Invoice"
required required
render={({ field }) => ( render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}> <Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select sale..." /> <SelectValue placeholder="Select invoice..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="" disabled>Select sale...</SelectItem> <SelectItem value="" disabled>Select invoice...</SelectItem>
{salesList.map((sale) => ( {pendingInvoices.map((inv) => (
<SelectItem key={sale.id} value={sale.id}> <SelectItem key={inv.id} value={inv.id}>
{truncId(sale.id)} — {fmtAmount(sale.totalAmount)} ({sale.paymentStatus}) {inv.storeName} — {fmtAmount(inv.totalAmount)} ({inv.paymentStatus}) [{truncId(inv.id)}]
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -150,10 +131,7 @@ export function PaymentSheet({ open, onClose, onSuccess }: PaymentSheetProps) {
label="Payment Method" label="Payment Method"
required required
render={({ field }) => ( render={({ field }) => (
<Select <Select value={field.value as string} onValueChange={field.onChange}>
value={field.value as string}
onValueChange={field.onChange}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select method" /> <SelectValue placeholder="Select method" />
</SelectTrigger> </SelectTrigger>
@@ -164,29 +142,6 @@ export function PaymentSheet({ open, onClose, onSuccess }: PaymentSheetProps) {
</Select> </Select>
)} )}
/> />
<FormField
control={form.control}
name="collectedByUserId"
label="Collected By"
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select user...</SelectItem>
{usersList.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.fullName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup> </FieldGroup>
<DialogFooter> <DialogFooter>
@@ -26,8 +26,7 @@ interface PaymentViewSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
payment: PaymentResponse | null payment: PaymentResponse | null
storeName?: string invoiceName?: string
collectedByName?: string
} }
function Row({ label, value }: { label: string; value: React.ReactNode }) { function Row({ label, value }: { label: string; value: React.ReactNode }) {
@@ -39,7 +38,7 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
) )
} }
export function PaymentViewSheet({ open, onClose, payment, storeName, collectedByName }: PaymentViewSheetProps) { export function PaymentViewSheet({ open, onClose, payment, invoiceName }: PaymentViewSheetProps) {
if (!payment) return null if (!payment) return null
return ( return (
@@ -51,12 +50,11 @@ export function PaymentViewSheet({ open, onClose, payment, storeName, collectedB
<div className="flex flex-col"> <div className="flex flex-col">
<Row label="Payment ID" value={<span className="font-mono">{truncId(payment.id)}</span>} /> <Row label="Payment ID" value={<span className="font-mono">{truncId(payment.id)}</span>} />
<Row label="Sale ID" value={<span className="font-mono">{truncId(payment.saleId)}</span>} /> <Row label="Invoice" value={invoiceName ?? <span className="font-mono">{truncId(payment.invoiceId)}</span>} />
<Row label="Store" value={storeName ?? <span className="font-mono">{truncId(payment.storeId)}</span>} />
<Row label="Amount" value={fmtAmount(payment.amount)} /> <Row label="Amount" value={fmtAmount(payment.amount)} />
<Row label="Method" value={<Badge variant={PAYMENT_METHOD_VARIANT[payment.method]}>{payment.method}</Badge>} /> <Row label="Method" value={<Badge variant={PAYMENT_METHOD_VARIANT[payment.method]}>{payment.method}</Badge>} />
<Row label="Collected At" value={fmtDateTime(payment.collectedAt)} /> <Row label="Collected At" value={fmtDateTime(payment.collectedAt)} />
<Row label="Collected By" value={collectedByName ?? <span className="font-mono">{truncId(payment.collectedBy)}</span>} /> <Row label="Collected By" value={<span className="font-mono">{truncId(payment.collectedBy)}</span>} />
<Row label="Created At" value={fmtDateTime(payment.createdAt)} /> <Row label="Created At" value={fmtDateTime(payment.createdAt)} />
</div> </div>
</DialogContent> </DialogContent>
@@ -8,9 +8,10 @@ import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table' import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { payments as paymentsApi, stores as storesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { invoices as invoicesApi, payments as paymentsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PaymentResponse, PaymentMethod, StoreResponse, UserResponse } from '@/types/api' import type { PaymentResponse, PaymentMethod, InvoiceResponse } from '@/types/api'
import { PaymentSheet } from './payment-sheet' import { PaymentSheet } from './payment-sheet'
import { PaymentViewSheet } from './payment-view-sheet' import { PaymentViewSheet } from './payment-view-sheet'
@@ -27,33 +28,39 @@ const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary'> = {
} }
export function PaymentsClient() { export function PaymentsClient() {
const [invoicesList, setInvoicesList] = useState<InvoiceResponse[]>([])
const [selectedInvoiceId, setSelectedInvoiceId] = useState('')
const [data, setData] = useState<PaymentResponse[]>([]) const [data, setData] = useState<PaymentResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(false)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [pageSize] = useState(20)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [viewOpen, setViewOpen] = useState(false) const [viewOpen, setViewOpen] = useState(false)
const [selectedPayment, setSelectedPayment] = useState<PaymentResponse | null>(null) const [selectedPayment, setSelectedPayment] = useState<PaymentResponse | null>(null)
const [storesList, setStoresList] = useState<StoreResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const storeNameById = useMemo( const invoiceNameById = useMemo(
() => new Map(storesList.map((s) => [s.id, s.storeName])), () => new Map(invoicesList.map((inv) => [inv.id, `${inv.storeName} — ${truncId(inv.id)}`])),
[storesList] [invoicesList]
)
const userNameById = useMemo(
() => new Map(usersList.map((u) => [u.id, u.fullName])),
[usersList]
) )
const fetchData = useCallback(async (p: number, ps: number) => { useEffect(() => {
invoicesApi.list({ pageSize: 200 })
.then((res) => setInvoicesList(getPaginatedItems(res)))
.catch((err) => toast.add({ title: 'Failed to load invoices', description: getApiError(err), type: 'error' }))
}, [])
const fetchData = useCallback(async (invoiceId: string) => {
if (!invoiceId) {
setData([])
setTotal(0)
return
}
setLoading(true) setLoading(true)
try { try {
const res = await paymentsApi.list({ page: p, pageSize: ps }) const items = await paymentsApi.listByInvoice(invoiceId)
setData(getPaginatedItems(res)) setData(items)
setTotal(res.total) setTotal(items.length)
} catch (err) { } catch (err) {
toast.add({ title: 'Failed to load payments', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to load payments', description: getApiError(err), type: 'error' })
} finally { } finally {
@@ -62,17 +69,8 @@ export function PaymentsClient() {
}, []) }, [])
useEffect(() => { useEffect(() => {
fetchData(page, pageSize) fetchData(selectedInvoiceId)
}, [fetchData, page, pageSize]) }, [fetchData, selectedInvoiceId])
useEffect(() => {
storesApi.list({ pageSize: 200 })
.then((res) => setStoresList(getPaginatedItems(res)))
.catch((err) => toast.add({ title: 'Failed to load stores', description: getApiError(err), type: 'error' }))
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [])
function handleView(row: PaymentResponse) { function handleView(row: PaymentResponse) {
setSelectedPayment(row) setSelectedPayment(row)
@@ -81,9 +79,8 @@ export function PaymentsClient() {
const tableConfig = useMemo<TableConfig<PaymentResponse>>(() => ({ const tableConfig = useMemo<TableConfig<PaymentResponse>>(() => ({
rowKey: 'id', rowKey: 'id',
serverSide: true,
stickyHeader: true, stickyHeader: true,
emptyMessage: 'No payments found.', emptyMessage: selectedInvoiceId ? 'No payments found for this invoice.' : 'Enter an invoice ID to view its payments.',
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
columns: [ columns: [
{ {
@@ -96,12 +93,12 @@ export function PaymentsClient() {
), ),
}, },
{ {
key: 'storeId', key: 'invoiceId',
title: 'Store', title: 'Invoice',
fields: ['storeId'], fields: ['invoiceId'],
minWidth: 160, minWidth: 180,
cellRender: (row) => ( cellRender: (row) => (
<span className="text-sm">{storeNameById.get(row.storeId) ?? truncId(row.storeId)}</span> <span className="text-sm">{invoiceNameById.get(row.invoiceId) ?? truncId(row.invoiceId)}</span>
), ),
}, },
{ {
@@ -128,15 +125,6 @@ export function PaymentsClient() {
fields: [{ key: 'collectedAt', transform: (v) => fmtDate(v as string) }], fields: [{ key: 'collectedAt', transform: (v) => fmtDate(v as string) }],
width: 140, width: 140,
}, },
{
key: 'collectedBy',
title: 'Collected By',
fields: ['collectedBy'],
minWidth: 160,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">{userNameById.get(row.collectedBy) ?? truncId(row.collectedBy)}</span>
),
},
], ],
rowActions: { rowActions: {
actions: () => [ actions: () => [
@@ -147,7 +135,7 @@ export function PaymentsClient() {
}, },
], ],
}, },
}), [pageSize, storeNameById, userNameById]) }), [pageSize, invoiceNameById])
return ( return (
<> <>
@@ -155,7 +143,7 @@ export function PaymentsClient() {
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">Payments</h1> <h1 className="text-2xl font-semibold text-foreground">Payments</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
Cash and credit payments collected from stores Cash and credit payments collected against invoices
</p> </p>
</div> </div>
<Button onClick={() => setSheetOpen(true)}> <Button onClick={() => setSheetOpen(true)}>
@@ -164,29 +152,38 @@ export function PaymentsClient() {
</Button> </Button>
</div> </div>
<div className="flex items-center gap-2 max-w-sm">
<Input
placeholder="Paste invoice ID to filter payments..."
value={selectedInvoiceId}
onChange={(e) => setSelectedInvoiceId(e.target.value.trim())}
/>
{selectedInvoiceId && (
<Button variant="ghost" size="sm" onClick={() => setSelectedInvoiceId('')}>
Clear
</Button>
)}
</div>
<DataTable <DataTable
config={tableConfig} config={tableConfig}
data={data} data={data}
loading={loading} loading={loading}
totalCount={total} totalCount={total}
onServerSideChange={({ page: p }) => {
setPage(p.page)
setPageSize(p.pageSize)
}}
/> />
<PaymentSheet <PaymentSheet
open={sheetOpen} open={sheetOpen}
onClose={() => setSheetOpen(false)} onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)} onSuccess={() => fetchData(selectedInvoiceId)}
invoices={invoicesList}
/> />
<PaymentViewSheet <PaymentViewSheet
open={viewOpen} open={viewOpen}
onClose={() => setViewOpen(false)} onClose={() => setViewOpen(false)}
payment={selectedPayment} payment={selectedPayment}
storeName={selectedPayment ? storeNameById.get(selectedPayment.storeId) : undefined} invoiceName={selectedPayment ? invoiceNameById.get(selectedPayment.invoiceId) : undefined}
collectedByName={selectedPayment ? userNameById.get(selectedPayment.collectedBy) : undefined}
/> />
</> </>
) )
@@ -50,7 +50,7 @@ type FormValues = z.infer<typeof schema>
interface StoreSheetProps { interface StoreSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
store: StoreResponse store: StoreResponse | null
onSuccess: () => void onSuccess: () => void
} }
@@ -81,7 +81,7 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
) )
form.reset({ if (store) form.reset({
storeName: store.storeName, storeName: store.storeName,
ownerName: store.ownerName, ownerName: store.ownerName,
storeAddress: store.storeAddress, storeAddress: store.storeAddress,
@@ -97,7 +97,7 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
try { try {
await storesApi.update(store.id, { await storesApi.update(store!.id, {
...values, ...values,
segment: values.segment || undefined, segment: values.segment || undefined,
}) })
@@ -10,7 +10,9 @@ export type RowActionKind =
| 'deactivate' | 'deactivate'
| 'confirm' | 'confirm'
| 'close' | 'close'
| 'cancel'; | 'cancel'
| 'print'
| 'finalize';
export interface BadgeConfig { export interface BadgeConfig {
colorMap?: Record<string, string>; colorMap?: Record<string, string>;
@@ -5,9 +5,11 @@ import { HugeiconsIcon } from "@hugeicons/react"
import { import {
Cancel01Icon, Cancel01Icon,
CheckmarkCircle02Icon, CheckmarkCircle02Icon,
CheckmarkSquare01Icon,
Delete02Icon, Delete02Icon,
EyeIcon, EyeIcon,
PencilEdit01Icon, PencilEdit01Icon,
PrinterIcon,
} from "@hugeicons/core-free-icons" } from "@hugeicons/core-free-icons"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -20,6 +22,8 @@ const ACTION_ICONS = {
confirm: CheckmarkCircle02Icon, confirm: CheckmarkCircle02Icon,
deactivate: Cancel01Icon, deactivate: Cancel01Icon,
edit: PencilEdit01Icon, edit: PencilEdit01Icon,
finalize: CheckmarkSquare01Icon,
print: PrinterIcon,
view: EyeIcon, view: EyeIcon,
} as const } as const
+5 -5
View File
@@ -60,12 +60,12 @@ export const NAV_GROUPS: NavGroup[] = [
], ],
}, },
{ {
title: 'Sales & Orders', title: 'Shop POs & Invoices',
icon: DollarSquareIcon, icon: DollarSquareIcon,
items: [ items: [
{ title: 'Sales', href: '/sales', icon: DollarSquareIcon, description: 'Sales transactions recorded against stores', permission: 'orders.view' }, { title: 'Shop POs', href: '/sales', icon: ClipboardListIcon, description: 'Purchase orders raised by sales agents on behalf of stores', permission: 'orders.view' },
{ title: 'Orders', href: '/sales/orders', icon: ClipboardListIcon, description: 'Fulfillment status of store orders', permission: 'orders.view' }, { title: 'Invoices', href: '/sales/orders', icon: DollarSquareIcon, description: 'Billing documents generated from confirmed Shop POs', permission: 'orders.view' },
{ title: 'Payments', href: '/sales/payments', icon: BankIcon, description: 'Cash and credit payments collected from stores', permission: 'orders.view' }, { title: 'Payments', href: '/sales/payments', icon: BankIcon, description: 'Cash and credit payments collected against invoices', permission: 'orders.view' },
], ],
}, },
{ {
@@ -161,6 +161,6 @@ export const BOTTOM_NAV_CANDIDATES: NavItem[] = [
{ title: 'Stores', href: '/stores', icon: Store01Icon, permission: 'stores.view' }, { title: 'Stores', href: '/stores', icon: Store01Icon, permission: 'stores.view' },
{ title: 'Rides', href: '/operations/rides', icon: TruckIcon, permission: 'rides.view' }, { title: 'Rides', href: '/operations/rides', icon: TruckIcon, permission: 'rides.view' },
{ title: 'Loading', href: '/operations/loading', icon: DeliveryBox01Icon, permission: 'loading.view' }, { title: 'Loading', href: '/operations/loading', icon: DeliveryBox01Icon, permission: 'loading.view' },
{ title: 'Sales', href: '/sales', icon: DollarSquareIcon, permission: 'orders.view' }, { title: 'Shop POs', href: '/sales', icon: ClipboardListIcon, permission: 'orders.view' },
{ title: 'Items', href: '/catalogue/items', icon: Package01Icon, permission: 'items.view' }, { title: 'Items', href: '/catalogue/items', icon: Package01Icon, permission: 'items.view' },
] ]
+1 -1
View File
@@ -8,7 +8,7 @@ export { drivers, compliance } from './drivers'
export { purchaseOrders, grn, purchaseReturns } from './procurement' export { purchaseOrders, grn, purchaseReturns } from './procurement'
export { rides, loadingSheets, deliveryTasks } from './operations' export { rides, loadingSheets, deliveryTasks } from './operations'
export { stock } from './stock' export { stock } from './stock'
export { sales, orders, payments } from './sales' export { shopPos, invoices, payments } from './sales'
export { saleReturns, claims, supplierClaims } from './returns-claims' export { saleReturns, claims, supplierClaims } from './returns-claims'
export { getPaginatedItems } from './paginated' export { getPaginatedItems } from './paginated'
+17 -48
View File
@@ -2,10 +2,8 @@ import { api } from '@/lib/api'
import type { import type {
PaginatedResponse, PaginatedResponse,
RideResponse, RideResponse,
CancelRequestResponse,
LoadingSheetResponse, LoadingSheetResponse,
DeliveryTaskResponse, DeliveryTaskResponse,
RideType,
RideStatus, RideStatus,
CancelRequestType, CancelRequestType,
LoadingSheetStatus, LoadingSheetStatus,
@@ -16,22 +14,18 @@ import type { PageParams } from './master'
// ─── Rides ──────────────────────────────────────────────────────────────────── // ─── Rides ────────────────────────────────────────────────────────────────────
export interface RideBody { export interface RideBody {
rideType: RideType
vehicleId: string vehicleId: string
driverProfileId: string driverProfileId: string
routeId: string
sourceWarehouseId: string
meterstart: number meterstart: number
} }
export interface RideListParams extends PageParams { export interface RideListParams extends PageParams {
status?: RideStatus status?: RideStatus
rideType?: RideType
driverProfileId?: string driverProfileId?: string
vehicleId?: string vehicleId?: string
} }
export interface CancelRequestBody { export interface CancelRideBody {
typeCode: CancelRequestType typeCode: CancelRequestType
reason: string reason: string
} }
@@ -49,43 +43,24 @@ export const rides = {
assignDriver: (id: string, driverProfileId: string) => assignDriver: (id: string, driverProfileId: string) =>
api.put<RideResponse>(`/rides/${id}/driver`, { driverProfileId }).then(r => r.data), api.put<RideResponse>(`/rides/${id}/driver`, { driverProfileId }).then(r => r.data),
activate: (id: string) =>
api.patch<RideResponse>(`/rides/${id}/activate`).then(r => r.data),
close: (id: string, meterend: number) => close: (id: string, meterend: number) =>
api.patch<RideResponse>(`/rides/${id}/close`, { meterend }).then(r => r.data), api.post<RideResponse>(`/rides/${id}/close`, { meterend }).then(r => r.data),
cancelRequests: { cancel: (id: string, body: CancelRideBody) =>
getForRide: (rideId: string) => api.post<RideResponse>(`/rides/${id}/cancel`, body).then(r => r.data),
api.get<CancelRequestResponse | null>(`/rides/${rideId}/cancel-request`).then(r => r.data),
raise: (rideId: string, body: CancelRequestBody) =>
api.post<CancelRequestResponse>(`/rides/${rideId}/cancel-request`, body).then(r => r.data),
approve: (requestId: string) =>
api.post<CancelRequestResponse>(`/rides/cancel-requests/${requestId}/approve`).then(r => r.data),
reject: (requestId: string) =>
api.post<CancelRequestResponse>(`/rides/cancel-requests/${requestId}/reject`).then(r => r.data),
},
} }
// ─── Loading Sheets ─────────────────────────────────────────────────────────── // ─── Loading Sheets ───────────────────────────────────────────────────────────
export interface LoadingSheetItemBody { export interface LoadingSheetCreateBody {
itemId: string fK_RideId: string
quantityDispatched: number
} }
export interface LoadingSheetBody { export interface LoadingSheetItemInput {
rideId: string fK_ItemId: string
sourceWarehouseId: string fK_WarehouseId: string
items: LoadingSheetItemBody[] quantity: number
} fK_ShopPOItemId?: string | null
export interface LoadingSheetReceiptItemBody {
itemId: string
quantityReceived: number
} }
export interface LoadingSheetListParams extends PageParams { export interface LoadingSheetListParams extends PageParams {
@@ -100,17 +75,14 @@ export const loadingSheets = {
get: (id: string) => get: (id: string) =>
api.get<LoadingSheetResponse>(`/loading-sheets/${id}`).then(r => r.data), api.get<LoadingSheetResponse>(`/loading-sheets/${id}`).then(r => r.data),
create: (body: LoadingSheetBody) => create: (body: LoadingSheetCreateBody) =>
api.post<LoadingSheetResponse>('/loading-sheets', body).then(r => r.data), api.post<LoadingSheetResponse>('/loading-sheets', body).then(r => r.data),
confirmDispatch: (id: string) => updateItems: (id: string, items: LoadingSheetItemInput[]) =>
api.patch<LoadingSheetResponse>(`/loading-sheets/${id}/dispatch`).then(r => r.data), api.put<LoadingSheetResponse>(`/loading-sheets/${id}/items`, { items }).then(r => r.data),
confirmReceipt: (id: string, items: LoadingSheetReceiptItemBody[]) => confirm: (id: string) =>
api.patch<LoadingSheetResponse>(`/loading-sheets/${id}/receipt`, { items }).then(r => r.data), api.post<LoadingSheetResponse>(`/loading-sheets/${id}/confirm`).then(r => r.data),
resolve: (id: string) =>
api.patch<LoadingSheetResponse>(`/loading-sheets/${id}/resolve`).then(r => r.data),
} }
// ─── Delivery Tasks ─────────────────────────────────────────────────────────── // ─── Delivery Tasks ───────────────────────────────────────────────────────────
@@ -121,11 +93,8 @@ export interface DeliveryTaskItemQuantity {
} }
export const deliveryTasks = { export const deliveryTasks = {
generate: (rideId: string) =>
api.post<DeliveryTaskResponse[]>(`/rides/${rideId}/tasks/generate`).then(r => r.data),
listByRide: (rideId: string) => listByRide: (rideId: string) =>
api.get<DeliveryTaskResponse[]>(`/rides/${rideId}/delivery-tasks`).then(r => r.data), api.get<DeliveryTaskResponse[]>(`/rides/${rideId}/tasks`).then(r => r.data),
get: (id: string) => get: (id: string) =>
api.get<DeliveryTaskResponse>(`/delivery-tasks/${id}`).then(r => r.data), api.get<DeliveryTaskResponse>(`/delivery-tasks/${id}`).then(r => r.data),
+4 -4
View File
@@ -35,11 +35,11 @@ export const saleReturns = {
get: (id: string) => get: (id: string) =>
api.get<SaleReturnResponse>(`/returns/${id}`).then(r => r.data), api.get<SaleReturnResponse>(`/returns/${id}`).then(r => r.data),
listByOrder: (orderId: string) => listByShopPO: (shopPOId: string) =>
api.get<SaleReturnResponse[]>(`/orders/${orderId}/returns`).then(r => r.data), api.get<SaleReturnResponse[]>(`/shop-pos/${shopPOId}/returns`).then(r => r.data),
create: (orderId: string, body: SaleReturnBody) => create: (shopPOId: string, body: SaleReturnBody) =>
api.post<SaleReturnResponse>(`/orders/${orderId}/returns`, body).then(r => r.data), api.post<SaleReturnResponse>(`/shop-pos/${shopPOId}/returns`, body).then(r => r.data),
updateReasons: (id: string, body: UpdateSaleReturnReasonsBody) => updateReasons: (id: string, body: UpdateSaleReturnReasonsBody) =>
api.patch<SaleReturnResponse>(`/returns/${id}/reasons`, body).then(r => r.data), api.patch<SaleReturnResponse>(`/returns/${id}/reasons`, body).then(r => r.data),
+56 -58
View File
@@ -1,101 +1,99 @@
import { api } from '@/lib/api' import { api } from '@/lib/api'
import type { import type {
PaginatedResponse, PaginatedResponse,
SaleResponse, ShopPOResponse,
OrderResponse, InvoiceResponse,
OrderStatus, InvoiceStatus,
PaymentResponse, PaymentResponse,
SaleStatus,
PaymentStatus,
PaymentMethod, PaymentMethod,
ShopPOStatus,
} from '@/types/api' } from '@/types/api'
import type { PageParams } from './master' import type { PageParams } from './master'
// ─── Sales ──────────────────────────────────────────────────────────────────── // ─── Shop POs ─────────────────────────────────────────────────────────────────
export interface SaleItemBody { export interface ShopPOItemBody {
itemId: string itemId: string
quantity: number quantity: number
unitPrice: number unitPrice?: number | null
} }
export interface SaleBody { export interface CreateShopPOBody {
storeId: string storeId: string
saleDate: string items: ShopPOItemBody[]
rideId?: string | null
fulfillImmediately?: boolean
items: SaleItemBody[]
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
createdByUserId: string
} }
export interface SaleListParams extends PageParams { export interface UpdateShopPOItemsBody {
storeId?: string items: ShopPOItemBody[]
status?: SaleStatus
paymentStatus?: PaymentStatus
rideId?: string
from?: string
to?: string
} }
export const sales = { export interface ShopPOListParams extends PageParams {
list: (params?: SaleListParams) => agentId?: string
api.get<PaginatedResponse<SaleResponse>>('/sales', { params }).then(r => r.data), routeId?: string
shopId?: string
confirmed?: boolean
status?: ShopPOStatus
}
export const shopPos = {
list: (params?: ShopPOListParams) =>
api.get<PaginatedResponse<ShopPOResponse>>('/shop-pos', { params }).then(r => r.data),
get: (id: string) => get: (id: string) =>
api.get<SaleResponse>(`/sales/${id}`).then(r => r.data), api.get<ShopPOResponse>(`/shop-pos/${id}`).then(r => r.data),
create: (body: SaleBody) => create: (body: CreateShopPOBody) =>
api.post<SaleResponse>('/sales', body).then(r => r.data), api.post<ShopPOResponse>('/shop-pos', body).then(r => r.data),
updateItems: (id: string, body: UpdateShopPOItemsBody) =>
api.put<ShopPOResponse>(`/shop-pos/${id}/items`, body).then(r => r.data),
confirm: (id: string) =>
api.post<ShopPOResponse>(`/shop-pos/${id}/confirm`).then(r => r.data),
confirmAndCreateInvoice: (id: string) =>
api.post<ShopPOResponse>(`/shop-pos/${id}/confirm-and-create-invoice`).then(r => r.data),
cancel: (id: string) => cancel: (id: string) =>
api.patch<SaleResponse>(`/sales/${id}/cancel`).then(r => r.data), api.post<ShopPOResponse>(`/shop-pos/${id}/cancel`).then(r => r.data),
} }
// ─── Orders ─────────────────────────────────────────────────────────────────── // ─── Invoices ─────────────────────────────────────────────────────────────────
// GET /orders takes no query params and returns a plain array — no server-side
// filtering/pagination exists on the backend, unlike Sales/Payments.
export const orders = { export interface InvoiceListParams extends PageParams {
list: () => agentId?: string
api.get<OrderResponse[]>('/orders').then(r => r.data), routeId?: string
shopId?: string
status?: InvoiceStatus
}
export const invoices = {
list: (params?: InvoiceListParams) =>
api.get<PaginatedResponse<InvoiceResponse>>('/invoices', { params }).then(r => r.data),
get: (id: string) => get: (id: string) =>
api.get<OrderResponse>(`/orders/${id}`).then(r => r.data), api.get<InvoiceResponse>(`/invoices/${id}`).then(r => r.data),
update: (id: string, status: OrderStatus) => print: (id: string) =>
api.put<OrderResponse>(`/orders/${id}`, { status }).then(r => r.data), api.post<InvoiceResponse>(`/invoices/${id}/print`).then(r => r.data),
delete: (id: string) => finalize: (id: string) =>
api.delete(`/orders/${id}`), api.post<InvoiceResponse>(`/invoices/${id}/finalize`).then(r => r.data),
} }
// ─── Payments ───────────────────────────────────────────────────────────────── // ─── Payments ─────────────────────────────────────────────────────────────────
export interface PaymentBody { export interface PaymentBody {
saleId: string
amount: number amount: number
method: PaymentMethod method: PaymentMethod
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. collectedByUserId?: string | null
collectedByUserId: string
}
export interface PaymentListParams extends PageParams {
storeId?: string
saleId?: string
method?: PaymentMethod
from?: string
to?: string
} }
export const payments = { export const payments = {
list: (params?: PaymentListParams) => listByInvoice: (invoiceId: string) =>
api.get<PaginatedResponse<PaymentResponse>>('/payments', { params }).then(r => r.data), api.get<PaymentResponse[]>(`/invoices/${invoiceId}/payments`).then(r => r.data),
get: (id: string) => create: (invoiceId: string, body: PaymentBody) =>
api.get<PaymentResponse>(`/payments/${id}`).then(r => r.data), api.post<PaymentResponse>(`/invoices/${invoiceId}/payments`, body).then(r => r.data),
create: (body: PaymentBody) =>
api.post<PaymentResponse>('/payments', body).then(r => r.data),
} }
+51 -67
View File
@@ -5,19 +5,15 @@ export type LocationType = 'Warehouse' | 'Vehicle' | 'External'
export type MovementType = export type MovementType =
| 'Sale' | 'CustomerReturn' | 'Transfer' | 'Damage' | 'Sale' | 'CustomerReturn' | 'Transfer' | 'Damage'
| 'Adjustment' | 'Load' | 'PurchaseReceipt' | 'PurchaseReturn' | 'Adjustment' | 'Load' | 'PurchaseReceipt' | 'PurchaseReturn'
export type LoadingSheetStatus = export type LoadingSheetStatus = 'Planned' | 'Confirmed'
| 'Pending' | 'DispatchConfirmed' | 'ReceiptConfirmed' export type RideStatus = 'Open' | 'Cancelled' | 'Closed'
| 'DiscrepancyFlagged' | 'Resolved'
export type RideType = 'Delivery' | 'Sale'
export type RideStatus =
| 'Draft' | 'Open' | 'Active' | 'CancelRequested' | 'Cancelled' | 'Closed'
export type CancelRequestType = export type CancelRequestType =
| 'VehicleBreakdown' | 'DriverUnavailable' | 'RouteIssue' | 'Other' | 'VehicleBreakdown' | 'DriverUnavailable' | 'RouteIssue' | 'Other'
export type CancelRequestStatus = 'Pending' | 'Approved' | 'Rejected'
export type PurchaseOrderStatus = export type PurchaseOrderStatus =
| 'Issued' | 'PartiallyReceived' | 'Received' | 'Closed' | 'Cancelled' | 'Issued' | 'PartiallyReceived' | 'Received' | 'Closed' | 'Cancelled'
export type SaleStatus = 'Pending' | 'Completed' | 'Cancelled' export type ShopPOStatus = 'Pending' | 'PartiallyFulfilled' | 'Fulfilled' | 'Cancelled'
export type OrderStatus = 'Pending' | 'PartiallyFulfilled' | 'Fulfilled' | 'Cancelled' export type InvoiceStatus = 'Draft' | 'Printed' | 'Finalized'
export type InvoiceCreationMode = 'ModeA' | 'ModeB'
export type PaymentStatus = 'Pending' | 'PartiallyPaid' | 'Paid' export type PaymentStatus = 'Pending' | 'PartiallyPaid' | 'Paid'
export type PaymentMethod = 'Cash' | 'Credit' export type PaymentMethod = 'Cash' | 'Credit'
export type DeliveryTaskStatus = 'Pending' | 'Delivered' | 'PartiallyDelivered' | 'Failed' export type DeliveryTaskStatus = 'Pending' | 'Delivered' | 'PartiallyDelivered' | 'Failed'
@@ -457,7 +453,7 @@ export interface SaleReturnItemResponse {
export interface SaleReturnResponse { export interface SaleReturnResponse {
id: string id: string
orderId: string shopPOId: string
mode: ReturnMode mode: ReturnMode
totalCreditAmount: number totalCreditAmount: number
orderTotalAmountAfter: number orderTotalAmountAfter: number
@@ -555,59 +551,45 @@ export interface DriverComplianceConfigResponse {
export interface RideResponse { export interface RideResponse {
id: string id: string
rideType: RideType
status: RideStatus status: RideStatus
vehicleId: string vehicleId: string
vehicleNumberPlate: string vehicleNumberPlate: string
driverProfileId: string driverProfileId: string
driverFullName: string driverFullName: string
routeId: string
routeName: string
sourceWarehouseId: string
sourceWarehouseName: string
meterstart: number meterstart: number
meterend: number meterend: number
startedAt: string | null startedAt: string | null
activatedAt: string | null
closedAt: string | null closedAt: string | null
createdAt: string createdAt: string
} }
export interface CancelRequestResponse {
id: string
rideId: string
typeCode: CancelRequestType
reason: string
requestedBy: string
requestedAt: string
status: CancelRequestStatus
approvedBy: string | null
approvedAt: string | null
}
// ─── Loading Sheets ─────────────────────────────────────────────────────────── // ─── Loading Sheets ───────────────────────────────────────────────────────────
export interface LoadingSheetItemResponse { export interface LoadingSheetItemResponse {
id: string id: string
fK_ItemId: string fK_ItemId: string
itemName: string itemName: string
fK_WarehouseId: string
warehouseName: string
quantityDispatched: number quantityDispatched: number
quantityReceived: number fK_ShopPOItemId: string | null
quantityResolved: number }
export interface LoadingSheetOrderRef {
shopPOId: string
} }
export interface LoadingSheetResponse { export interface LoadingSheetResponse {
id: string id: string
fK_RideId: string fK_RideId: string
fK_SourceWarehouseId: string
warehouseName: string
status: LoadingSheetStatus status: LoadingSheetStatus
dispatchConfirmedAt: string | null populatedBy: string
dispatchConfirmedBy: string | null populatedAt: string
receiptConfirmedAt: string | null confirmedAt: string | null
receiptConfirmedBy: string | null confirmedBy: string | null
createdAt: string createdAt: string
items: LoadingSheetItemResponse[] items: LoadingSheetItemResponse[]
orders: LoadingSheetOrderRef[]
} }
// ─── Delivery Tasks ─────────────────────────────────────────────────────────── // ─── Delivery Tasks ───────────────────────────────────────────────────────────
@@ -623,7 +605,7 @@ export interface DeliveryTaskItemResponse {
export interface DeliveryTaskResponse { export interface DeliveryTaskResponse {
id: string id: string
fK_RideId: string fK_RideId: string
fK_OrderId: string fK_ShopPOId: string
storeId: string storeId: string
storeName: string storeName: string
sequence: number sequence: number
@@ -635,58 +617,53 @@ export interface DeliveryTaskResponse {
createdAt: string createdAt: string
} }
// ─── Sales / Orders / Payments ──────────────────────────────────────────────── // ─── ShopPO / Invoice / Payment ───────────────────────────────────────────────
export interface SaleItemResponse { export interface ShopPOItemResponse {
id: string
itemId: string itemId: string
itemName: string
quantity: number quantity: number
quantityFulfilled: number quantityFulfilled: number
unitPrice: number unitPrice: number
isActive: boolean
} }
export interface SaleResponse { export interface ShopPOResponse {
id: string id: string
saleDate: string
storeId: string storeId: string
createdByUserId: string storeName: string
rideId: string | null raisedById: string
invoiceId: string | null
totalAmount: number totalAmount: number
status: SaleStatus status: ShopPOStatus
paymentStatus: PaymentStatus confirmedAt: string | null
fulfillImmediately: boolean confirmedBy: string | null
completedAt: string | null fulfilledAt: string | null
orderId: string items: ShopPOItemResponse[]
orderStatus: OrderStatus
orderFulfilledAt: string | null
items: SaleItemResponse[]
isActive: boolean isActive: boolean
createdAt: string createdAt: string
} }
export interface OrderItemResponse { export interface InvoiceResponse {
itemId: string
quantity: number
quantityFulfilled: number
unitPrice: number
}
export interface OrderResponse {
id: string id: string
saleId: string shopPOId: string
orderDate: string storeName: string
storeId: string status: InvoiceStatus
paymentStatus: PaymentStatus
createdBy: string
printedAt: string | null
printedBy: string | null
finalizedAt: string | null
finalizedBy: string | null
totalAmount: number totalAmount: number
status: OrderStatus
fulfilledAt: string | null
items: OrderItemResponse[]
isActive: boolean isActive: boolean
createdAt: string createdAt: string
} }
export interface PaymentResponse { export interface PaymentResponse {
id: string id: string
saleId: string invoiceId: string
storeId: string
amount: number amount: number
method: PaymentMethod method: PaymentMethod
collectedAt: string collectedAt: string
@@ -694,6 +671,13 @@ export interface PaymentResponse {
createdAt: string createdAt: string
} }
export interface SysConfigResponse {
key: string
value: string
updatedAt: string
updatedBy: string | null
}
// ─── API Error ──────────────────────────────────────────────────────────────── // ─── API Error ────────────────────────────────────────────────────────────────
export interface ApiError { export interface ApiError {